diff --git a/app/config/collections/platform.php b/app/config/collections/platform.php index e919df8e1a..b2e077ad61 100644 --- a/app/config/collections/platform.php +++ b/app/config/collections/platform.php @@ -330,7 +330,18 @@ $platformCollections = [ 'default' => null, 'array' => false, 'filters' => ['datetime'], - ] + ], + [ + '$id' => ID::custom('labels'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 128, + 'signed' => true, + 'required' => false, + 'default' => [], + 'array' => true, + 'filters' => [], + ], ], 'indexes' => [ [ diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 49c0003588..40bde3baf3 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -204,6 +204,7 @@ App::post('/v1/projects') 'accessedAt' => DateTime::now(), 'search' => implode(' ', [$projectId, $name]), 'database' => $dsn, + 'labels' => [], ])); } catch (Duplicate) { throw new Exception(Exception::PROJECT_ALREADY_EXISTS); diff --git a/app/init/registers.php b/app/init/registers.php index be2009449e..1b58c85aa4 100644 --- a/app/init/registers.php +++ b/app/init/registers.php @@ -360,6 +360,8 @@ $register->set('smtp', function () { $mail->SMTPSecure = System::getEnv('_APP_SMTP_SECURE', ''); $mail->SMTPAutoTLS = false; $mail->CharSet = 'UTF-8'; + $mail->Timeout = 10; /* Connection timeout */ + $mail->getSMTPInstance()->Timelimit = 30; /* Timeout for each individual SMTP command (e.g. HELO, EHLO, etc.) */ $from = \urldecode(System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server')); $email = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM); diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Labels/Update.php b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Labels/Update.php new file mode 100644 index 0000000000..1a06c1ee84 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Labels/Update.php @@ -0,0 +1,86 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PUT) + ->setHttpPath('/v1/projects/:projectId/labels') + ->desc('Update project labels') + ->groups(['api', 'projects']) + ->label('scope', 'projects.write') + ->label('sdk', new Method( + namespace: 'projects', + group: 'projects', + name: 'updateLabels', + description: <<param('projectId', '', new UID(), 'Project unique ID.') + ->param('labels', [], new ArrayList(new Text(36, allowList: [...Text::NUMBERS, ...Text::ALPHABET_UPPER, ...Text::ALPHABET_LOWER]), APP_LIMIT_ARRAY_LABELS_SIZE), 'Array of project labels. Replaces the previous labels. Maximum of ' . APP_LIMIT_ARRAY_LABELS_SIZE . ' labels are allowed, each up to 36 alphanumeric characters long.') + ->inject('response') + ->inject('dbForPlatform') + ->callback($this->action(...)); + } + + /** + * @param array $labels + */ + public function action( + string $projectId, + array $labels, + Response $response, + Database $dbForPlatform + ): void { + $project = $dbForPlatform->getDocument('projects', $projectId); + + if ($project->isEmpty()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + + $project->setAttribute('labels', (array) \array_values(\array_unique($labels))); + + $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Projects/Services/Http.php b/src/Appwrite/Platform/Modules/Projects/Services/Http.php index 2a0dd0aa60..cce05a9570 100644 --- a/src/Appwrite/Platform/Modules/Projects/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Projects/Services/Http.php @@ -7,6 +7,7 @@ use Appwrite\Platform\Modules\Projects\Http\DevKeys\Delete as DeleteDevKey; use Appwrite\Platform\Modules\Projects\Http\DevKeys\Get as GetDevKey; use Appwrite\Platform\Modules\Projects\Http\DevKeys\Update as UpdateDevKey; use Appwrite\Platform\Modules\Projects\Http\DevKeys\XList as ListDevKeys; +use Appwrite\Platform\Modules\Projects\Http\Projects\Labels\Update as UpdateProjectLabels; use Appwrite\Platform\Modules\Projects\Http\Projects\XList as ListProjects; use Utopia\Platform\Service; @@ -22,5 +23,6 @@ class Http extends Service $this->addAction(DeleteDevKey::getName(), new DeleteDevKey()); $this->addAction(ListProjects::getName(), new ListProjects()); + $this->addAction(UpdateProjectLabels::getName(), new UpdateProjectLabels()); } } diff --git a/src/Appwrite/Platform/Workers/Deletes.php b/src/Appwrite/Platform/Workers/Deletes.php index 5c36fa7f7a..dbe1882294 100644 --- a/src/Appwrite/Platform/Workers/Deletes.php +++ b/src/Appwrite/Platform/Workers/Deletes.php @@ -148,7 +148,7 @@ class Deletes extends Action break; case DELETE_TYPE_AUDIT: if (!$project->isEmpty()) { - $this->deleteAuditLogs($project, $auditRetention, $getAudit); + $this->deleteAuditLogs($project, $getAudit, $auditRetention); } break; case DELETE_TYPE_REALTIME: @@ -783,14 +783,13 @@ class Deletes extends Action } /** - * @param Database $dbForPlatform - * @param callable $getProjectDB - * @param string $auditRetention + * @param Document $project * @param callable $getAudit + * @param string $auditRetention * @return void * @throws Exception */ - private function deleteAuditLogs(Document $project, string $auditRetention, callable $getAudit): void + private function deleteAuditLogs(Document $project, callable $getAudit, string $auditRetention): void { $projectId = $project->getId(); /** @var Audit $audit */ diff --git a/src/Appwrite/Platform/Workers/Mails.php b/src/Appwrite/Platform/Workers/Mails.php index 01448620f3..b1f17fc648 100644 --- a/src/Appwrite/Platform/Workers/Mails.php +++ b/src/Appwrite/Platform/Workers/Mails.php @@ -68,7 +68,8 @@ class Mails extends Action throw new Exception('Skipped mail processing. No SMTP configuration has been set.'); } - $log->addTag('type', empty($smtp) ? 'cloud' : 'smtp'); + $type = empty($smtp) ? 'cloud' : 'smtp'; + $log->addTag('type', $type); $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') == 'disabled' ? 'http' : 'https'; $hostname = System::getEnv('_APP_CONSOLE_DOMAIN'); @@ -182,6 +183,9 @@ class Mails extends Action try { $mail->send(); } catch (\Throwable $error) { + if ($type === 'smtp') { + throw new Exception('Error sending mail: ' . $error->getMessage(), 401); + } throw new Exception('Error sending mail: ' . $error->getMessage(), 500); } } @@ -209,6 +213,8 @@ class Mails extends Action $mail->SMTPSecure = $smtp['secure']; $mail->SMTPAutoTLS = false; $mail->CharSet = 'UTF-8'; + $mail->Timeout = 10; /* Connection timeout */ + $mail->getSMTPInstance()->Timelimit = 30; /* Timeout for each individual SMTP command (e.g. HELO, EHLO, etc.) */ $mail->setFrom($smtp['senderEmail'], $smtp['senderName']); diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/Projects.php b/src/Appwrite/Utopia/Database/Validator/Queries/Projects.php index d179703274..d96e373949 100644 --- a/src/Appwrite/Utopia/Database/Validator/Queries/Projects.php +++ b/src/Appwrite/Utopia/Database/Validator/Queries/Projects.php @@ -6,7 +6,8 @@ class Projects extends Base { public const ALLOWED_ATTRIBUTES = [ 'name', - 'teamId' + 'teamId', + 'labels', ]; /** diff --git a/src/Appwrite/Utopia/Response/Model/Project.php b/src/Appwrite/Utopia/Response/Model/Project.php index 7641e96090..c516aab73f 100644 --- a/src/Appwrite/Utopia/Response/Model/Project.php +++ b/src/Appwrite/Utopia/Response/Model/Project.php @@ -276,6 +276,13 @@ class Project extends Model 'default' => '', 'example' => self::TYPE_DATETIME_EXAMPLE, ]) + ->addRule('labels', [ + 'type' => self::TYPE_STRING, + 'description' => 'Labels for the project.', + 'default' => [], + 'example' => ['vip'], + 'array' => true, + ]) ; $services = Config::getParam('services', []); diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index fae6031672..d055b876f9 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -5382,4 +5382,216 @@ class ProjectsConsoleClientTest extends Scope /** * Devkeys Tests ends here ------------------------------------------------ */ + + public function testProjectLabels(): void + { + // Setup: Prepare team + $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'teamId' => ID::unique(), + 'name' => 'Query Select Test Team', + ]); + + $this->assertEquals(201, $team['headers']['status-code']); + $teamId = $team['body']['$id']; + + // Setup: Prepare project + $project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'projectId' => ID::unique(), + 'name' => 'Test project - Labels 1', + 'teamId' => $teamId, + 'region' => System::getEnv('_APP_REGION', 'default') + ]); + + $this->assertEquals(201, $project['headers']['status-code']); + $this->assertIsArray($project['body']['labels']); + $this->assertCount(0, $project['body']['labels']); + $projectId = $project['body']['$id']; + + // Apply labels + $project = $this->client->call(Client::METHOD_PUT, '/projects/' . $projectId . '/labels', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'labels' => ['vip', 'imagine', 'blocked'] + ]); + + $this->assertEquals(200, $project['headers']['status-code']); + $this->assertIsArray($project['body']['labels']); + $this->assertCount(3, $project['body']['labels']); + $this->assertEquals('vip', $project['body']['labels'][0]); + $this->assertEquals('imagine', $project['body']['labels'][1]); + $this->assertEquals('blocked', $project['body']['labels'][2]); + + // Update labels + $project = $this->client->call(Client::METHOD_PUT, '/projects/' . $projectId . '/labels', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'labels' => ['nonvip', 'imagine'] + ]); + $this->assertEquals(200, $project['headers']['status-code']); + $this->assertIsArray($project['body']['labels']); + $this->assertCount(2, $project['body']['labels']); + $this->assertEquals('nonvip', $project['body']['labels'][0]); + $this->assertEquals('imagine', $project['body']['labels'][1]); + + // Filter by labels + $projects = $this->client->call(Client::METHOD_GET, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::contains('labels', ['nonvip'])->toString(), + ] + ]); + $this->assertEquals(200, $projects['headers']['status-code']); + $this->assertEquals(1, $projects['body']['total']); + $this->assertEquals($projectId, $projects['body']['projects'][0]['$id']); + + $projects = $this->client->call(Client::METHOD_GET, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::contains('labels', ['vip'])->toString(), + ] + ]); + $this->assertEquals(200, $projects['headers']['status-code']); + $this->assertEquals(0, $projects['body']['total']); + + $projects = $this->client->call(Client::METHOD_GET, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::contains('labels', ['imagine'])->toString(), + ] + ]); + $this->assertEquals(200, $projects['headers']['status-code']); + $this->assertEquals(1, $projects['body']['total']); + $this->assertEquals($projectId, $projects['body']['projects'][0]['$id']); + + $projects = $this->client->call(Client::METHOD_GET, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::contains('labels', ['nonvip', 'imagine'])->toString(), + ] + ]); + $this->assertEquals(200, $projects['headers']['status-code']); + $this->assertEquals(1, $projects['body']['total']); + $this->assertEquals($projectId, $projects['body']['projects'][0]['$id']); + + // Setup: Second project with only imagine label + $project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'projectId' => ID::unique(), + 'name' => 'Test project - Labels 2', + 'teamId' => $teamId, + 'region' => System::getEnv('_APP_REGION', 'default') + ]); + + $this->assertEquals(201, $project['headers']['status-code']); + $this->assertIsArray($project['body']['labels']); + $this->assertCount(0, $project['body']['labels']); + $projectId2 = $project['body']['$id']; + + $project = $this->client->call(Client::METHOD_PUT, '/projects/' . $projectId2 . '/labels', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'labels' => ['vip', 'imagine'] + ]); + $this->assertEquals(200, $project['headers']['status-code']); + $this->assertIsArray($project['body']['labels']); + $this->assertCount(2, $project['body']['labels']); + $this->assertEquals('vip', $project['body']['labels'][0]); + $this->assertEquals('imagine', $project['body']['labels'][1]); + + // List of imagine has both + $projects = $this->client->call(Client::METHOD_GET, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::contains('labels', ['imagine'])->toString(), + ] + ]); + $this->assertEquals(200, $projects['headers']['status-code']); + $this->assertEquals(2, $projects['body']['total']); + $this->assertEquals($projectId, $projects['body']['projects'][0]['$id']); + $this->assertEquals($projectId2, $projects['body']['projects'][1]['$id']); + + // List of vip only has second + $projects = $this->client->call(Client::METHOD_GET, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::contains('labels', ['vip'])->toString(), + ] + ]); + $this->assertEquals(200, $projects['headers']['status-code']); + $this->assertEquals(1, $projects['body']['total']); + $this->assertEquals($projectId2, $projects['body']['projects'][0]['$id']); + + // List of vip and imagine has second + $projects = $this->client->call(Client::METHOD_GET, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::contains('labels', ['vip'])->toString(), + Query::contains('labels', ['imagine'])->toString(), + ] + ]); + $this->assertEquals(200, $projects['headers']['status-code']); + $this->assertEquals(1, $projects['body']['total']); + $this->assertEquals($projectId2, $projects['body']['projects'][0]['$id']); + + // List of vip or imagine has second + $projects = $this->client->call(Client::METHOD_GET, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::contains('labels', ['vip', 'imagine'])->toString(), + ] + ]); + $this->assertEquals(200, $projects['headers']['status-code']); + $this->assertEquals(2, $projects['body']['total']); + $this->assertEquals($projectId, $projects['body']['projects'][0]['$id']); + $this->assertEquals($projectId2, $projects['body']['projects'][1]['$id']); + + // Cleanup + $response = $this->client->call(Client::METHOD_DELETE, '/projects/' . $projectId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(204, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_DELETE, '/projects/' . $projectId2, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(204, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_DELETE, '/teams/' . $teamId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(204, $response['headers']['status-code']); + } }