diff --git a/frontend/database/174_add_apitokens_name_uniqueness.sql b/frontend/database/174_add_apitokens_name_uniqueness.sql new file mode 100644 index 0000000000..4adf3ed6e3 --- /dev/null +++ b/frontend/database/174_add_apitokens_name_uniqueness.sql @@ -0,0 +1,3 @@ +-- API_Tokens +ALTER TABLE `API_Tokens` + ADD CONSTRAINT UNIQUE `user_name` (`user_id`, `name`); diff --git a/frontend/database/schema.sql b/frontend/database/schema.sql index ca92bd1d03..cec08a2620 100644 --- a/frontend/database/schema.sql +++ b/frontend/database/schema.sql @@ -26,6 +26,7 @@ CREATE TABLE `API_Tokens` ( `use_count` int NOT NULL DEFAULT '0' COMMENT 'Número de usos desde la última hora', PRIMARY KEY (`apitoken_id`), UNIQUE KEY `token` (`token`), + UNIQUE KEY `user_name` (`user_id`,`name`), KEY `user_id` (`user_id`), CONSTRAINT `fk_atu_user_id` FOREIGN KEY (`user_id`) REFERENCES `Users` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Tokens para el API'; diff --git a/frontend/server/src/Controllers/README.md b/frontend/server/src/Controllers/README.md index 44ee4715fa..2073aec070 100644 --- a/frontend/server/src/Controllers/README.md +++ b/frontend/server/src/Controllers/README.md @@ -211,6 +211,7 @@ - [`/api/user/coderOfTheMonthList/`](#apiusercoderofthemonthlist) - [`/api/user/contestStats/`](#apiuserconteststats) - [`/api/user/create/`](#apiusercreate) + - [`/api/user/createAPIToken/`](#apiusercreateapitoken) - [`/api/user/extraInformation/`](#apiuserextrainformation) - [`/api/user/generateGitToken/`](#apiusergenerategittoken) - [`/api/user/generateOmiUsers/`](#apiusergenerateomiusers) @@ -227,6 +228,7 @@ - [`/api/user/removeExperiment/`](#apiuserremoveexperiment) - [`/api/user/removeGroup/`](#apiuserremovegroup) - [`/api/user/removeRole/`](#apiuserremoverole) + - [`/api/user/revokeAPIToken/`](#apiuserrevokeapitoken) - [`/api/user/selectCoderOfTheMonth/`](#apiuserselectcoderofthemonth) - [`/api/user/stats/`](#apiuserstats) - [`/api/user/statusVerified/`](#apiuserstatusverified) @@ -4077,6 +4079,50 @@ Entry point for Create a User API | ---------- | -------- | | `username` | `string` | +## `/api/user/createAPIToken/` + +### Description + +Creates a new API token associated with the user. + +This token can be used to authenticate against the API in other calls +through the [HTTP `Authorization` +header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization) +in the request: + +``` +Authorization: token 92d8c5a0eceef3c05f4149fc04b62bb2cd50d9c6 +``` + +The following alternative syntax allows to specify an associated +identity: + +``` +Authorization: token Credential=92d8c5a0eceef3c05f4149fc04b62bb2cd50d9c6,Username=groupname:username +``` + +There is a limit of 1000 requests that can be done every hour, after +which point all requests will fail with [HTTP 429 Too Many +Requests](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429). +The `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and +`X-RateLimit-Reset` response headers will be set whenever an API token +is used and will contain useful information about the limit to the +caller. + +There is a limit of 5 API tokens that each user can have. + +### Parameters + +| Name | Type | Description | +| ------ | -------- | -------------------------------------------------------------------- | +| `name` | `string` | A non-empty alphanumeric string. May contain underscores and dashes. | + +### Returns + +| Name | Type | +| ------- | -------- | +| `token` | `string` | + ## `/api/user/extraInformation/` ### Description @@ -4370,6 +4416,22 @@ Removes the role from the user. _Nothing_ +## `/api/user/revokeAPIToken/` + +### Description + +Revokes an API token associated with the user. + +### Parameters + +| Name | Type | Description | +| ------ | -------- | -------------------------------------------------------------------- | +| `name` | `string` | A non-empty alphanumeric string. May contain underscores and dashes. | + +### Returns + +_Nothing_ + ## `/api/user/selectCoderOfTheMonth/` ### Description diff --git a/frontend/server/src/Controllers/User.php b/frontend/server/src/Controllers/User.php index af13aa74d3..4dc1b4b225 100644 --- a/frontend/server/src/Controllers/User.php +++ b/frontend/server/src/Controllers/User.php @@ -4102,6 +4102,100 @@ public static function getLoginDetailsForTypeScript(\OmegaUp\Request $r) { } return $response; } + + /** + * Creates a new API token associated with the user. + * + * This token can be used to authenticate against the API in other calls + * through the [HTTP `Authorization` + * header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization) + * in the request: + * + * ``` + * Authorization: token 92d8c5a0eceef3c05f4149fc04b62bb2cd50d9c6 + * ``` + * + * The following alternative syntax allows to specify an associated + * identity: + * + * ``` + * Authorization: token Credential=92d8c5a0eceef3c05f4149fc04b62bb2cd50d9c6,Username=groupname:username + * ``` + * + * There is a limit of 1000 requests that can be done every hour, after + * which point all requests will fail with [HTTP 429 Too Many + * Requests](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429). + * The `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and + * `X-RateLimit-Reset` response headers will be set whenever an API token + * is used and will contain useful information about the limit to the + * caller. + * + * There is a limit of 5 API tokens that each user can have. + * + * @return array{token: string} + * + * @omegaup-request-param string $name A non-empty alphanumeric string. May contain underscores and dashes. + */ + public static function apiCreateAPIToken(\OmegaUp\Request $r) { + \OmegaUp\Controllers\Controller::ensureNotInLockdown(); + $r->ensureMainUserIdentity(); + + $name = $r->ensureString( + 'name', + fn (string $name) => preg_match('/^[a-zA-Z0-9_-]+$/', $name) === 1, + ); + $token = \OmegaUp\SecurityTools::randomHexString(40); + $apiToken = new \OmegaUp\DAO\VO\APITokens([ + 'user_id' => $r->user->user_id, + 'token' => $token, + 'name' => $name, + ]); + + try { + \OmegaUp\DAO\DAO::transBegin(); + + \OmegaUp\DAO\APITokens::create($apiToken); + if (\OmegaUp\DAO\APITokens::getCountByUser($r->user->user_id) > 5) { + throw new \OmegaUp\Exceptions\DuplicatedEntryInDatabaseException( + 'apiTokenLimitExceeded' + ); + } + + \OmegaUp\DAO\DAO::transEnd(); + return [ + 'token' => $token, + ]; + } catch (\Exception $e) { + \OmegaUp\DAO\DAO::transRollback(); + if (\OmegaUp\DAO\DAO::isDuplicateEntryException($e)) { + throw new \OmegaUp\Exceptions\DuplicatedEntryInDatabaseException( + 'apiTokenNameAlreadyInUse', + $e + ); + } + throw $e; + } + } + + /** + * Revokes an API token associated with the user. + * + * @return array{status: string} + * + * @omegaup-request-param string $name A non-empty alphanumeric string. May contain underscores and dashes. + */ + public static function apiRevokeAPIToken(\OmegaUp\Request $r) { + \OmegaUp\Controllers\Controller::ensureNotInLockdown(); + $r->ensureMainUserIdentity(); + + $name = $r->ensureString( + 'name', + fn (string $name) => preg_match('/^[a-zA-Z0-9_-]+$/', $name) === 1, + ); + + \OmegaUp\DAO\APITokens::deleteByName($r->user->user_id, $name); + return ['status' => 'ok']; + } } \OmegaUp\Controllers\User::$urlHelper = new \OmegaUp\UrlHelper(); diff --git a/frontend/server/src/DAO/APITokens.php b/frontend/server/src/DAO/APITokens.php index 2680c68aa8..841b05dff6 100644 --- a/frontend/server/src/DAO/APITokens.php +++ b/frontend/server/src/DAO/APITokens.php @@ -215,4 +215,34 @@ public static function updateUsage( throw $e; } } + + public static function getCountByUser(int $userId): int { + /** @var int */ + return \OmegaUp\MySQLConnection::getInstance()->GetOne( + ' + SELECT + COUNT(*) + FROM + `API_Tokens` at + WHERE + at.user_id = ?; + ', + [$userId], + ); + } + + public static function deleteByName(int $userId, string $name): void { + \OmegaUp\MySQLConnection::getInstance()->Execute( + ' + DELETE FROM + `API_Tokens` + WHERE + user_id = ? AND name = ?; + ', + [$userId, $name], + ); + if (\OmegaUp\MySQLConnection::getInstance()->Affected_Rows() == 0) { + throw new \OmegaUp\Exceptions\NotFoundException('recordNotFound'); + } + } } diff --git a/frontend/server/src/Exceptions/RateLimitExceededException.php b/frontend/server/src/Exceptions/RateLimitExceededException.php index 64d2df9bb0..99516dc8a6 100644 --- a/frontend/server/src/Exceptions/RateLimitExceededException.php +++ b/frontend/server/src/Exceptions/RateLimitExceededException.php @@ -4,7 +4,7 @@ class RateLimitExceededException extends \OmegaUp\Exceptions\ApiException { public function __construct( - string $message = 'rateLimitExceeded', + string $message = 'apiTokenRateLimitExceeded', ?\Exception $previous = null ) { parent::__construct( diff --git a/frontend/server/src/Psalm/TranslationStringChecker.php b/frontend/server/src/Psalm/TranslationStringChecker.php index 548983b8ee..14db232288 100644 --- a/frontend/server/src/Psalm/TranslationStringChecker.php +++ b/frontend/server/src/Psalm/TranslationStringChecker.php @@ -11,13 +11,13 @@ class TranslationStringChecker implements * A list of messages that are present in the base exception classes. */ const EXCEPTION_MESSAGES = [ + 'apiTokenRateLimitExceeded', 'csrfException', 'emailNotVerified', 'errorWhileSendingMail', 'generalError', 'loginRequired', 'problemDeployerFailed', - 'rateLimitExceeded', 'resourceNotFound', 'unableToVerifyCaptcha', 'userNotAllowed', diff --git a/frontend/templates/en.lang b/frontend/templates/en.lang index 247794e57a..8e41871aa3 100644 --- a/frontend/templates/en.lang +++ b/frontend/templates/en.lang @@ -25,6 +25,9 @@ aliasAlreadyInUse = "Alias \"%(alias)\" already exists. Please choose a differen aliasInUse = "alias already exists. Please choose a different alias." allowedSolutionsLimitReached = "You have reached the limit of allowed solutions" apiNotFound = "Requested API endpoint not found." +apiTokenLimitExceeded = "Maximum number of API tokens exceeded." +apiTokenNameAlreadyInUse = "API token names must be unique" +apiTokenRateLimitExceeded = "Maximum number of API requests exceeded. Please try again later." arenaClarificationCreate = "Type your clarification here:" arenaClarificationCreateMaxLength = "Type your clarification here (Max. 200 characters, do not paste your entire code)." arenaClarificationMaxLength = "Max. 200 characters, do not paste your entire code." @@ -1068,7 +1071,6 @@ qualityNominationType = "Type" rankRangeHeader = "Top %(lowCount) to %(highCount) users with the most points" rankScore = "Score" rankSolved = "Solved problems" -rateLimitExceeded = "Maximum number of API requests exceeded. Please try again later." recordNotFound = "Record not found." registerForContest = "Register for contest" registerForContestChallenges = "Challenge" diff --git a/frontend/templates/es.lang b/frontend/templates/es.lang index ef35bca65e..f2c994674c 100644 --- a/frontend/templates/es.lang +++ b/frontend/templates/es.lang @@ -25,6 +25,9 @@ aliasAlreadyInUse = "El alias \"%(alias)\" ya está siendo usado. Por favor elig aliasInUse = "El alias (título corto) ya está siendo usado. Por favor elige un alias distinto." allowedSolutionsLimitReached = "Has alcanzado el límite de soluciones permitidas" apiNotFound = "El API solicitada no fue encontrada." +apiTokenLimitExceeded = "Número máximo de tokens de API excedido." +apiTokenNameAlreadyInUse = "Los nombres de los tokens deben ser únicos" +apiTokenRateLimitExceeded = "Límite de peticiones al API excedido. Por favor intenta de nuevo más tarde." arenaClarificationCreate = "Escribe tu pregunta aquí:" arenaClarificationCreateMaxLength = "Escribe tu pregunta aquí (Máximo 200 caracteres, no incluyas tu código completo)." arenaClarificationMaxLength = "Máximo 200 caracteres, no incluyas tu código completo." @@ -1068,7 +1071,6 @@ qualityNominationType = "Tipo" rankRangeHeader = "Los mejores %(lowCount) a %(highCount) usuarios con más puntaje" rankScore = "Score" rankSolved = "Problemas" -rateLimitExceeded = "Límite de peticiones al API excedido. Por favor intenta de nuevo más tarde." recordNotFound = "Registro no encontrado." registerForContest = "Registrarse en el concurso" registerForContestChallenges = "Reto" diff --git a/frontend/templates/pseudo.lang b/frontend/templates/pseudo.lang index a323329478..05a908b0ef 100644 --- a/frontend/templates/pseudo.lang +++ b/frontend/templates/pseudo.lang @@ -25,6 +25,9 @@ aliasAlreadyInUse = "(A1ia5 \"%(alias)\" a1r3ady 3xi575. P13a53 ch0053 a diff3r3 aliasInUse = "(a1ia5 a1r3ady 3xi575. P13a53 ch0053 a diff3r3n7 a1ia5.)" allowedSolutionsLimitReached = "(Y0u hav3 r3ach3d 7h3 1imi7 0f a110w3d 501u7i0n5)" apiNotFound = "(R3qu3573d API 3ndp0in7 n07 f0und.)" +apiTokenLimitExceeded = "(Maximum numb3r 0f API 70k3n5 3xc33d3d.)" +apiTokenNameAlreadyInUse = "(API 70k3n nam35 mu57 b3 uniqu3)" +apiTokenRateLimitExceeded = "(Maximum numb3r 0f API r3qu3575 3xc33d3d. P13a53 7ry again 1a73r.)" arenaClarificationCreate = "(Typ3 y0ur c1arifica7i0n h3r3:)" arenaClarificationCreateMaxLength = "(Typ3 y0ur c1arifica7i0n h3r3 (Max. 200 charac73r5, d0 n07 pa573 y0ur 3n7ir3 c0d3).)" arenaClarificationMaxLength = "(Max. 200 charac73r5, d0 n07 pa573 y0ur 3n7ir3 c0d3.)" @@ -1068,7 +1071,6 @@ qualityNominationType = "(Typ3)" rankRangeHeader = "(T0p %(lowCount) 70 %(highCount) u53r5 wi7h 7h3 m057 p0in75)" rankScore = "(Sc0r3)" rankSolved = "(S01v3d pr0b13m5)" -rateLimitExceeded = "(Maximum numb3r 0f API r3qu3575 3xc33d3d. P13a53 7ry again 1a73r.)" recordNotFound = "(R3c0rd n07 f0und.)" registerForContest = "(R3gi573r f0r c0n7357)" registerForContestChallenges = "(Cha113ng3)" diff --git a/frontend/templates/pt.lang b/frontend/templates/pt.lang index ffd8ee6238..f23cfab3c8 100644 --- a/frontend/templates/pt.lang +++ b/frontend/templates/pt.lang @@ -25,6 +25,9 @@ aliasAlreadyInUse = "Alias \"%(alias)\" já existe. Por favor, escolha um nome d aliasInUse = "Alias já existe. Por favor, escolha um alias diferente." allowedSolutionsLimitReached = "Você tem alcançado o limite de soluçoes permitidas" apiNotFound = "Endpoint API solicitada não foi encontrado." +apiTokenLimitExceeded = "Número máximo de tokens da API excedido." +apiTokenNameAlreadyInUse = "Os nomes de tokens da API devem ser exclusivos" +apiTokenRateLimitExceeded = "Número máximo de solicitações da API excedido. Por favor, tente novamente mais tarde." arenaClarificationCreate = "Digite sua pergunta aqui" arenaClarificationCreateMaxLength = "Digite sua pergunta aqui (no máximo 200 caracteres, não incluir o seu código completo)" arenaClarificationMaxLength = "No máximo 200 caracteres, não incluir o seu código completo." @@ -1068,7 +1071,6 @@ qualityNominationType = "Tipo" rankRangeHeader = "Os melhores %(lowCount) a %(highCount) usuários com maior score obtido" rankScore = "Pontuação" rankSolved = "Problemas" -rateLimitExceeded = "Número máximo de solicitações de API excedido. Por favor, tente novamente mais tarde." recordNotFound = "Registro não foi encontrado." registerForContest = "cadastre-se para o concurso" registerForContestChallenges = "Desafio" diff --git a/frontend/tests/Utils.php b/frontend/tests/Utils.php index 6703dadb2f..4547e22f04 100644 --- a/frontend/tests/Utils.php +++ b/frontend/tests/Utils.php @@ -202,6 +202,7 @@ private static function cleanupDB(): void { 'ACLs', 'Assignments', 'Auth_Tokens', + 'API_Tokens', 'Clarifications', 'Coder_Of_The_Month', 'Contest_Log', diff --git a/frontend/tests/controllers/APITokenTest.php b/frontend/tests/controllers/APITokenTest.php index aac50467c7..12b93c8604 100644 --- a/frontend/tests/controllers/APITokenTest.php +++ b/frontend/tests/controllers/APITokenTest.php @@ -82,7 +82,7 @@ public function testAPITokensRateLimitsWork() { $this->fail('Should not have been able to access the API'); } catch (\OmegaUp\Exceptions\RateLimitExceededException $e) { $this->assertEquals( - 'rateLimitExceeded', + 'apiTokenRateLimitExceeded', $e->getMessage(), ); } @@ -283,4 +283,148 @@ public function testAPITokensWithUnrelatedUserFails() { ); } } + + public function testAPITokenNameUniqueness() { + [ + 'user' => $user, + 'identity' => $identity, + ] = \OmegaUp\Test\Factories\User::createUser(); + $login = self::login($identity); + \OmegaUp\Controllers\User::apiCreateAPIToken( + new \OmegaUp\Request([ + 'auth_token' => $login->auth_token, + 'name' => 'my-token', + ]), + ); + try { + \OmegaUp\Controllers\User::apiCreateAPIToken( + new \OmegaUp\Request([ + 'auth_token' => $login->auth_token, + 'name' => 'my-token', + ]), + ); + $this->fail( + 'Should not have been able to create a duplicate token name' + ); + } catch (\OmegaUp\Exceptions\DuplicatedEntryInDatabaseException $e) { + $this->assertEquals( + 'apiTokenNameAlreadyInUse', + $e->getMessage(), + ); + } + } + + public function testAPITokenLimit() { + [ + 'user' => $user, + 'identity' => $identity, + ] = \OmegaUp\Test\Factories\User::createUser(); + $login = self::login($identity); + for ($i = 0; $i < 5; $i++) { + \OmegaUp\Controllers\User::apiCreateAPIToken( + new \OmegaUp\Request([ + 'auth_token' => $login->auth_token, + 'name' => "my-token-{$i}", + ]), + ); + } + try { + \OmegaUp\Controllers\User::apiCreateAPIToken( + new \OmegaUp\Request([ + 'auth_token' => $login->auth_token, + 'name' => 'my-token-5', + ]), + ); + $this->fail('Should not have been able to create a sixth token'); + } catch (\OmegaUp\Exceptions\DuplicatedEntryInDatabaseException $e) { + $this->assertEquals( + 'apiTokenLimitExceeded', + $e->getMessage(), + ); + } + } + + public function testAPITokensCanBeRevoked() { + $mockSessionManager = $this->getMockBuilder( + \OmegaUp\SessionManager::class + ) + ->setMethods(['setHeader']) + ->getMock(); + $mockSessionManager + ->expects($this->any()) + ->method('setHeader'); + \OmegaUp\Controllers\Session::setSessionManagerForTesting( + $mockSessionManager + ); + + [ + 'user' => $user, + 'identity' => $identity, + ] = \OmegaUp\Test\Factories\User::createUser(); + $login = self::login($identity); + $token = \OmegaUp\Controllers\User::apiCreateAPIToken( + new \OmegaUp\Request([ + 'auth_token' => $login->auth_token, + 'name' => 'my-token', + ]), + )['token']; + unset($login); + \OmegaUp\Controllers\Session::invalidateCache(); + \OmegaUp\Controllers\Session::invalidateLocalCache(); + + $_SERVER['HTTP_AUTHORIZATION'] = "token {$token}"; + $session = \OmegaUp\Controllers\Session::apiCurrentSession(); + $this->assertTrue($session['session']['valid']); + $this->assertEquals( + $identity->username, + $session['session']['identity']->username, + ); + unset($_SERVER['HTTP_AUTHORIZATION']); + \OmegaUp\Controllers\Session::invalidateCache(); + \OmegaUp\Controllers\Session::invalidateLocalCache(); + + $login = self::login($identity); + \OmegaUp\Controllers\User::apiRevokeAPIToken( + new \OmegaUp\Request([ + 'auth_token' => $login->auth_token, + 'name' => 'my-token', + ]), + ); + unset($login); + \OmegaUp\Controllers\Session::invalidateCache(); + \OmegaUp\Controllers\Session::invalidateLocalCache(); + + $_SERVER['HTTP_AUTHORIZATION'] = "token {$token}"; + try { + \OmegaUp\Controllers\Session::apiCurrentSession(); + $this->fail('Should not have been able to access the API'); + } catch (\OmegaUp\Exceptions\UnauthorizedException $e) { + $this->assertEquals( + 'loginRequired', + $e->getMessage() + ); + } + } + + public function testAPITokensWithUnknownNamesCannotBeRevoked() { + [ + 'user' => $user, + 'identity' => $identity, + ] = \OmegaUp\Test\Factories\User::createUser(); + + $login = self::login($identity); + try { + \OmegaUp\Controllers\User::apiRevokeAPIToken( + new \OmegaUp\Request([ + 'auth_token' => $login->auth_token, + 'name' => 'my-token', + ]), + ); + } catch (\OmegaUp\Exceptions\NotFoundException $e) { + $this->assertEquals( + 'recordNotFound', + $e->getMessage() + ); + } + } } diff --git a/frontend/www/js/omegaup/api.ts b/frontend/www/js/omegaup/api.ts index a46669bfc6..c87b340d2a 100644 --- a/frontend/www/js/omegaup/api.ts +++ b/frontend/www/js/omegaup/api.ts @@ -1718,6 +1718,10 @@ export const User = { create: apiCall( '/api/user/create/', ), + createAPIToken: apiCall< + messages.UserCreateAPITokenRequest, + messages.UserCreateAPITokenResponse + >('/api/user/createAPIToken/'), extraInformation: apiCall< messages.UserExtraInformationRequest, messages._UserExtraInformationServerResponse, @@ -1796,6 +1800,10 @@ export const User = { messages.UserRemoveRoleRequest, messages.UserRemoveRoleResponse >('/api/user/removeRole/'), + revokeAPIToken: apiCall< + messages.UserRevokeAPITokenRequest, + messages.UserRevokeAPITokenResponse + >('/api/user/revokeAPIToken/'), selectCoderOfTheMonth: apiCall< messages.UserSelectCoderOfTheMonthRequest, messages.UserSelectCoderOfTheMonthResponse diff --git a/frontend/www/js/omegaup/api_types.ts b/frontend/www/js/omegaup/api_types.ts index bad0744687..7705a19363 100644 --- a/frontend/www/js/omegaup/api_types.ts +++ b/frontend/www/js/omegaup/api_types.ts @@ -4057,6 +4057,8 @@ export namespace messages { }; export type UserCreateRequest = { [key: string]: any }; export type UserCreateResponse = { username: string }; + export type UserCreateAPITokenRequest = { [key: string]: any }; + export type UserCreateAPITokenResponse = { token: string }; export type UserExtraInformationRequest = { [key: string]: any }; export type _UserExtraInformationServerResponse = any; export type UserExtraInformationResponse = { @@ -4107,6 +4109,8 @@ export namespace messages { export type UserRemoveGroupResponse = {}; export type UserRemoveRoleRequest = { [key: string]: any }; export type UserRemoveRoleResponse = {}; + export type UserRevokeAPITokenRequest = { [key: string]: any }; + export type UserRevokeAPITokenResponse = {}; export type UserSelectCoderOfTheMonthRequest = { [key: string]: any }; export type UserSelectCoderOfTheMonthResponse = {}; export type UserStatsRequest = { [key: string]: any }; @@ -4773,6 +4777,9 @@ export namespace controllers { create: ( params?: messages.UserCreateRequest, ) => Promise; + createAPIToken: ( + params?: messages.UserCreateAPITokenRequest, + ) => Promise; extraInformation: ( params?: messages.UserExtraInformationRequest, ) => Promise; @@ -4821,6 +4828,9 @@ export namespace controllers { removeRole: ( params?: messages.UserRemoveRoleRequest, ) => Promise; + revokeAPIToken: ( + params?: messages.UserRevokeAPITokenRequest, + ) => Promise; selectCoderOfTheMonth: ( params?: messages.UserSelectCoderOfTheMonthRequest, ) => Promise; diff --git a/frontend/www/js/omegaup/lang.en.json b/frontend/www/js/omegaup/lang.en.json index 66175bca16..43acde1674 100644 --- a/frontend/www/js/omegaup/lang.en.json +++ b/frontend/www/js/omegaup/lang.en.json @@ -26,6 +26,9 @@ "aliasInUse": "alias already exists. Please choose a different alias.", "allowedSolutionsLimitReached": "You have reached the limit of allowed solutions", "apiNotFound": "Requested API endpoint not found.", + "apiTokenLimitExceeded": "Maximum number of API tokens exceeded.", + "apiTokenNameAlreadyInUse": "API token names must be unique", + "apiTokenRateLimitExceeded": "Maximum number of API requests exceeded. Please try again later.", "arenaClarificationCreate": "Type your clarification here:", "arenaClarificationCreateMaxLength": "Type your clarification here (Max. 200 characters, do not paste your entire code).", "arenaClarificationMaxLength": "Max. 200 characters, do not paste your entire code.", @@ -1069,7 +1072,6 @@ "rankRangeHeader": "Top %(lowCount) to %(highCount) users with the most points", "rankScore": "Score", "rankSolved": "Solved problems", - "rateLimitExceeded": "Maximum number of API requests exceeded. Please try again later.", "recordNotFound": "Record not found.", "registerForContest": "Register for contest", "registerForContestChallenges": "Challenge", diff --git a/frontend/www/js/omegaup/lang.en.ts b/frontend/www/js/omegaup/lang.en.ts index 00e17257ca..4b261ec5af 100644 --- a/frontend/www/js/omegaup/lang.en.ts +++ b/frontend/www/js/omegaup/lang.en.ts @@ -27,6 +27,9 @@ const translations: { [key: string]: string; } = { aliasInUse: "alias already exists. Please choose a different alias.", allowedSolutionsLimitReached: "You have reached the limit of allowed solutions", apiNotFound: "Requested API endpoint not found.", + apiTokenLimitExceeded: "Maximum number of API tokens exceeded.", + apiTokenNameAlreadyInUse: "API token names must be unique", + apiTokenRateLimitExceeded: "Maximum number of API requests exceeded. Please try again later.", arenaClarificationCreate: "Type your clarification here:", arenaClarificationCreateMaxLength: "Type your clarification here (Max. 200 characters, do not paste your entire code).", arenaClarificationMaxLength: "Max. 200 characters, do not paste your entire code.", @@ -1070,7 +1073,6 @@ const translations: { [key: string]: string; } = { rankRangeHeader: "Top %(lowCount) to %(highCount) users with the most points", rankScore: "Score", rankSolved: "Solved problems", - rateLimitExceeded: "Maximum number of API requests exceeded. Please try again later.", recordNotFound: "Record not found.", registerForContest: "Register for contest", registerForContestChallenges: "Challenge", diff --git a/frontend/www/js/omegaup/lang.es.json b/frontend/www/js/omegaup/lang.es.json index 783c3cdd70..d6206a5e01 100644 --- a/frontend/www/js/omegaup/lang.es.json +++ b/frontend/www/js/omegaup/lang.es.json @@ -26,6 +26,9 @@ "aliasInUse": "El alias (t\u00edtulo corto) ya est\u00e1 siendo usado. Por favor elige un alias distinto.", "allowedSolutionsLimitReached": "Has alcanzado el l\u00edmite de soluciones permitidas", "apiNotFound": "El API solicitada no fue encontrada.", + "apiTokenLimitExceeded": "N\u00famero m\u00e1ximo de tokens de API excedido.", + "apiTokenNameAlreadyInUse": "Los nombres de los tokens deben ser \u00fanicos", + "apiTokenRateLimitExceeded": "L\u00edmite de peticiones al API excedido. Por favor intenta de nuevo m\u00e1s tarde.", "arenaClarificationCreate": "Escribe tu pregunta aqu\u00ed:", "arenaClarificationCreateMaxLength": "Escribe tu pregunta aqu\u00ed (M\u00e1ximo 200 caracteres, no incluyas tu c\u00f3digo completo).", "arenaClarificationMaxLength": "M\u00e1ximo 200 caracteres, no incluyas tu c\u00f3digo completo.", @@ -1069,7 +1072,6 @@ "rankRangeHeader": "Los mejores %(lowCount) a %(highCount) usuarios con m\u00e1s puntaje", "rankScore": "Score", "rankSolved": "Problemas", - "rateLimitExceeded": "L\u00edmite de peticiones al API excedido. Por favor intenta de nuevo m\u00e1s tarde.", "recordNotFound": "Registro no encontrado.", "registerForContest": "Registrarse en el concurso", "registerForContestChallenges": "Reto", diff --git a/frontend/www/js/omegaup/lang.es.ts b/frontend/www/js/omegaup/lang.es.ts index 139596763b..b1c48793da 100644 --- a/frontend/www/js/omegaup/lang.es.ts +++ b/frontend/www/js/omegaup/lang.es.ts @@ -27,6 +27,9 @@ const translations: { [key: string]: string; } = { aliasInUse: "El alias (t\u00edtulo corto) ya est\u00e1 siendo usado. Por favor elige un alias distinto.", allowedSolutionsLimitReached: "Has alcanzado el l\u00edmite de soluciones permitidas", apiNotFound: "El API solicitada no fue encontrada.", + apiTokenLimitExceeded: "N\u00famero m\u00e1ximo de tokens de API excedido.", + apiTokenNameAlreadyInUse: "Los nombres de los tokens deben ser \u00fanicos", + apiTokenRateLimitExceeded: "L\u00edmite de peticiones al API excedido. Por favor intenta de nuevo m\u00e1s tarde.", arenaClarificationCreate: "Escribe tu pregunta aqu\u00ed:", arenaClarificationCreateMaxLength: "Escribe tu pregunta aqu\u00ed (M\u00e1ximo 200 caracteres, no incluyas tu c\u00f3digo completo).", arenaClarificationMaxLength: "M\u00e1ximo 200 caracteres, no incluyas tu c\u00f3digo completo.", @@ -1070,7 +1073,6 @@ const translations: { [key: string]: string; } = { rankRangeHeader: "Los mejores %(lowCount) a %(highCount) usuarios con m\u00e1s puntaje", rankScore: "Score", rankSolved: "Problemas", - rateLimitExceeded: "L\u00edmite de peticiones al API excedido. Por favor intenta de nuevo m\u00e1s tarde.", recordNotFound: "Registro no encontrado.", registerForContest: "Registrarse en el concurso", registerForContestChallenges: "Reto", diff --git a/frontend/www/js/omegaup/lang.pseudo.json b/frontend/www/js/omegaup/lang.pseudo.json index 14166b5423..5810640e18 100644 --- a/frontend/www/js/omegaup/lang.pseudo.json +++ b/frontend/www/js/omegaup/lang.pseudo.json @@ -26,6 +26,9 @@ "aliasInUse": "(a1ia5 a1r3ady 3xi575. P13a53 ch0053 a diff3r3n7 a1ia5.)", "allowedSolutionsLimitReached": "(Y0u hav3 r3ach3d 7h3 1imi7 0f a110w3d 501u7i0n5)", "apiNotFound": "(R3qu3573d API 3ndp0in7 n07 f0und.)", + "apiTokenLimitExceeded": "(Maximum numb3r 0f API 70k3n5 3xc33d3d.)", + "apiTokenNameAlreadyInUse": "(API 70k3n nam35 mu57 b3 uniqu3)", + "apiTokenRateLimitExceeded": "(Maximum numb3r 0f API r3qu3575 3xc33d3d. P13a53 7ry again 1a73r.)", "arenaClarificationCreate": "(Typ3 y0ur c1arifica7i0n h3r3:)", "arenaClarificationCreateMaxLength": "(Typ3 y0ur c1arifica7i0n h3r3 (Max. 200 charac73r5, d0 n07 pa573 y0ur 3n7ir3 c0d3).)", "arenaClarificationMaxLength": "(Max. 200 charac73r5, d0 n07 pa573 y0ur 3n7ir3 c0d3.)", @@ -1069,7 +1072,6 @@ "rankRangeHeader": "(T0p %(lowCount) 70 %(highCount) u53r5 wi7h 7h3 m057 p0in75)", "rankScore": "(Sc0r3)", "rankSolved": "(S01v3d pr0b13m5)", - "rateLimitExceeded": "(Maximum numb3r 0f API r3qu3575 3xc33d3d. P13a53 7ry again 1a73r.)", "recordNotFound": "(R3c0rd n07 f0und.)", "registerForContest": "(R3gi573r f0r c0n7357)", "registerForContestChallenges": "(Cha113ng3)", diff --git a/frontend/www/js/omegaup/lang.pseudo.ts b/frontend/www/js/omegaup/lang.pseudo.ts index a37bd7cec8..81908ca460 100644 --- a/frontend/www/js/omegaup/lang.pseudo.ts +++ b/frontend/www/js/omegaup/lang.pseudo.ts @@ -27,6 +27,9 @@ const translations: { [key: string]: string; } = { aliasInUse: "(a1ia5 a1r3ady 3xi575. P13a53 ch0053 a diff3r3n7 a1ia5.)", allowedSolutionsLimitReached: "(Y0u hav3 r3ach3d 7h3 1imi7 0f a110w3d 501u7i0n5)", apiNotFound: "(R3qu3573d API 3ndp0in7 n07 f0und.)", + apiTokenLimitExceeded: "(Maximum numb3r 0f API 70k3n5 3xc33d3d.)", + apiTokenNameAlreadyInUse: "(API 70k3n nam35 mu57 b3 uniqu3)", + apiTokenRateLimitExceeded: "(Maximum numb3r 0f API r3qu3575 3xc33d3d. P13a53 7ry again 1a73r.)", arenaClarificationCreate: "(Typ3 y0ur c1arifica7i0n h3r3:)", arenaClarificationCreateMaxLength: "(Typ3 y0ur c1arifica7i0n h3r3 (Max. 200 charac73r5, d0 n07 pa573 y0ur 3n7ir3 c0d3).)", arenaClarificationMaxLength: "(Max. 200 charac73r5, d0 n07 pa573 y0ur 3n7ir3 c0d3.)", @@ -1070,7 +1073,6 @@ const translations: { [key: string]: string; } = { rankRangeHeader: "(T0p %(lowCount) 70 %(highCount) u53r5 wi7h 7h3 m057 p0in75)", rankScore: "(Sc0r3)", rankSolved: "(S01v3d pr0b13m5)", - rateLimitExceeded: "(Maximum numb3r 0f API r3qu3575 3xc33d3d. P13a53 7ry again 1a73r.)", recordNotFound: "(R3c0rd n07 f0und.)", registerForContest: "(R3gi573r f0r c0n7357)", registerForContestChallenges: "(Cha113ng3)", diff --git a/frontend/www/js/omegaup/lang.pt.json b/frontend/www/js/omegaup/lang.pt.json index e97ffb3df8..957156b8da 100644 --- a/frontend/www/js/omegaup/lang.pt.json +++ b/frontend/www/js/omegaup/lang.pt.json @@ -26,6 +26,9 @@ "aliasInUse": "Alias j\u00e1 existe. Por favor, escolha um alias diferente.", "allowedSolutionsLimitReached": "Voc\u00ea tem alcan\u00e7ado o limite de solu\u00e7oes permitidas", "apiNotFound": "Endpoint API solicitada n\u00e3o foi encontrado.", + "apiTokenLimitExceeded": "N\u00famero m\u00e1ximo de tokens da API excedido.", + "apiTokenNameAlreadyInUse": "Os nomes de tokens da API devem ser exclusivos", + "apiTokenRateLimitExceeded": "N\u00famero m\u00e1ximo de solicita\u00e7\u00f5es da API excedido. Por favor, tente novamente mais tarde.", "arenaClarificationCreate": "Digite sua pergunta aqui", "arenaClarificationCreateMaxLength": "Digite sua pergunta aqui (no m\u00e1ximo 200 caracteres, n\u00e3o incluir o seu c\u00f3digo completo)", "arenaClarificationMaxLength": "No m\u00e1ximo 200 caracteres, n\u00e3o incluir o seu c\u00f3digo completo.", @@ -1069,7 +1072,6 @@ "rankRangeHeader": "Os melhores %(lowCount) a %(highCount) usu\u00e1rios com maior score obtido", "rankScore": "Pontua\u00e7\u00e3o", "rankSolved": "Problemas", - "rateLimitExceeded": "N\u00famero m\u00e1ximo de solicita\u00e7\u00f5es de API excedido. Por favor, tente novamente mais tarde.", "recordNotFound": "Registro n\u00e3o foi encontrado.", "registerForContest": "cadastre-se para o concurso", "registerForContestChallenges": "Desafio", diff --git a/frontend/www/js/omegaup/lang.pt.ts b/frontend/www/js/omegaup/lang.pt.ts index 09c3e1642b..3f96c8d8e2 100644 --- a/frontend/www/js/omegaup/lang.pt.ts +++ b/frontend/www/js/omegaup/lang.pt.ts @@ -27,6 +27,9 @@ const translations: { [key: string]: string; } = { aliasInUse: "Alias j\u00e1 existe. Por favor, escolha um alias diferente.", allowedSolutionsLimitReached: "Voc\u00ea tem alcan\u00e7ado o limite de solu\u00e7oes permitidas", apiNotFound: "Endpoint API solicitada n\u00e3o foi encontrado.", + apiTokenLimitExceeded: "N\u00famero m\u00e1ximo de tokens da API excedido.", + apiTokenNameAlreadyInUse: "Os nomes de tokens da API devem ser exclusivos", + apiTokenRateLimitExceeded: "N\u00famero m\u00e1ximo de solicita\u00e7\u00f5es da API excedido. Por favor, tente novamente mais tarde.", arenaClarificationCreate: "Digite sua pergunta aqui", arenaClarificationCreateMaxLength: "Digite sua pergunta aqui (no m\u00e1ximo 200 caracteres, n\u00e3o incluir o seu c\u00f3digo completo)", arenaClarificationMaxLength: "No m\u00e1ximo 200 caracteres, n\u00e3o incluir o seu c\u00f3digo completo.", @@ -1070,7 +1073,6 @@ const translations: { [key: string]: string; } = { rankRangeHeader: "Os melhores %(lowCount) a %(highCount) usu\u00e1rios com maior score obtido", rankScore: "Pontua\u00e7\u00e3o", rankSolved: "Problemas", - rateLimitExceeded: "N\u00famero m\u00e1ximo de solicita\u00e7\u00f5es de API excedido. Por favor, tente novamente mais tarde.", recordNotFound: "Registro n\u00e3o foi encontrado.", registerForContest: "cadastre-se para o concurso", registerForContestChallenges: "Desafio",