Update HTTPX configs and plugin loading according to the new docs for Client Credentials, Bearer and Basic auth

This commit is contained in:
Marcello Rocha
2026-03-02 18:25:57 +01:00
parent 6a80b8064b
commit d4e841cfce
6 changed files with 472 additions and 26 deletions
+4 -4
View File
@@ -59,11 +59,10 @@ module OpenProject
private_class_method def self.httpx_session
session = HTTPX
.plugin(:oauth)
.plugin(:persistent)
.plugin(:basic_auth)
.plugin(:webdav)
.with(headers: { "User-Agent" => "OpenProject #{OpenProject::VERSION.to_semver} HTTPX Client" })
.plugin(:auth)
.plugin(:persistent)
.plugin(:webdav)
.with(
timeout: {
connect_timeout: OpenProject::Configuration.httpx_connect_timeout,
@@ -74,6 +73,7 @@ module OpenProject
keep_alive_timeout: OpenProject::Configuration.httpx_keep_alive_timeout
}
)
OpenProject::Appsignal.enabled? ? session.plugin(HttpxAppsignal) : session
end
end
@@ -40,7 +40,7 @@ module Storages
return build_failure(storage) if username.blank? || password.blank?
yield OpenProject.httpx.basic_auth(username, password).with(http_options)
yield OpenProject.httpx.plugin(:basic_auth).basic_auth(username, password).with(http_options)
end
private
@@ -61,6 +61,10 @@ module Storages
end
operation_result
rescue HTTPX::HTTPError => e
Failure(Results::Error.new(code: :unauthorized, payload: e.response, source: self.class))
rescue HTTPX::TimeoutError => e
Failure(Results::Error.new(code: :timeout, payload: e.to_s, source: self.class))
end
private
@@ -73,7 +77,7 @@ module Storages
end
def write_cache(key, httpx_session)
access_token = httpx_session.instance_variable_get(:@options).oauth_session.access_token
access_token = httpx_session.send(:oauth_session).access_token
Rails.cache.write(key, access_token, expires_in: 50.minutes)
end
@@ -88,20 +92,11 @@ module Storages
end
def http_with_current_token(access_token:, http_options:)
opts = http_options.deep_merge({ headers: { "Authorization" => "Bearer #{access_token}" } })
Success(OpenProject.httpx.with(opts))
Success(OpenProject.httpx.bearer_auth(access_token).with(http_options))
end
def http_with_new_token(config:, http_options:)
http = OpenProject.httpx
.oauth_auth(**config.to_h, token_endpoint_auth_method: "client_secret_post")
.with_access_token
.with(http_options)
Success(http)
rescue HTTPX::HTTPError => e
Failure(Results::Error.new(code: :unauthorized, payload: e.response, source: self.class))
rescue HTTPX::TimeoutError => e
Failure(Results::Error.new(code: :timeout, payload: e.to_s, source: self.class))
Success(OpenProject.httpx.plugin(:oauth).with(**http_options, oauth_options: config.to_h))
end
end
end
@@ -38,13 +38,13 @@ module Storages
let(:storage) { create(:nextcloud_storage) }
let(:access_token) { "my_access_token" }
it "must yield with passed access token without" do
it "must yield with the passed access token" do
strategy = Input::Strategy.build(key: :bearer_token, token: access_token)
was_yielded = false
Authentication[strategy].call(storage:) do |http|
was_yielded = true
expect(http.instance_variable_get(:@options).headers["authorization"]).to eq("Bearer #{access_token}")
expect(http.instance_variable_get(:@options).auth_header_value).to eq(access_token)
end
expect(was_yielded).to be_truthy
@@ -43,7 +43,7 @@ module Storages
context "with valid oauth credentials", vcr: "auth/one_drive/client_credentials" do
it "return success" do
result = Authentication[strategy_data].call(storage:, http_options: {}) { |http| make_request(http) }
result = Authentication[strategy_data].call(storage:) { make_request(it) }
expect(result).to be_success
expect(result.value!).to eq("EXPECTED_RESULT")
@@ -51,9 +51,7 @@ module Storages
it "caches the token if use_cache is true" do
strategy_data = Input::Strategy.build(key: :oauth_client_credentials, use_cache: true)
Authentication[strategy_data].call(storage:, http_options: {}) do |http|
make_request(http)
end
Authentication[strategy_data].call(storage:) { make_request(it) }
cache_key = described_class::TOKEN_CACHE_KEY % storage.id
expect(Rails.cache.read(cache_key)).not_to be_nil
@@ -62,7 +60,7 @@ module Storages
context "with invalid client secret", vcr: "auth/one_drive/client_credentials_invalid_client_secret" do
it "must return unauthorized" do
result = Authentication[strategy_data].call(storage:) { |http| make_request(http) }
result = Authentication[strategy_data].call(storage:) { make_request(it) }
expect(result).to be_failure
error = result.failure
@@ -73,7 +71,7 @@ module Storages
context "with invalid client id", vcr: "auth/one_drive/client_credentials_invalid_client_id" do
it "must return unauthorized" do
result = Authentication[strategy_data].call(storage:) { |http| make_request(http) }
result = Authentication[strategy_data].call(storage:) { make_request(it) }
expect(result).to be_failure
error = result.failure
@@ -84,7 +82,9 @@ module Storages
private
def make_request(http) = handle_response(http.get(request_url))
def make_request(http)
handle_response(http.get(request_url))
end
def handle_response(response)
case response
@@ -0,0 +1,451 @@
---
http_interactions:
- request:
method: get
uri: https://nextcloud.local/ocs/v1.php/cloud/user
body:
encoding: US-ASCII
string: ''
headers:
User-Agent:
- OpenProject 17.2.0 HTTPX Client
Accept:
- "*/*"
Accept-Encoding:
- gzip, deflate
Authorization:
- Bearer <SECRET>
response:
status:
code: 401
message: Unauthorized
headers:
Cache-Control:
- no-cache, no-store, must-revalidate
Content-Security-Policy:
- default-src 'none';base-uri 'none';manifest-src 'self';frame-ancestors 'none'
Content-Type:
- application/xml; charset=utf-8
Date:
- Mon, 02 Mar 2026 16:08:46 GMT
Feature-Policy:
- autoplay 'none';camera 'none';fullscreen 'none';geolocation 'none';microphone
'none';payment 'none'
Referrer-Policy:
- no-referrer
Server:
- Apache/2.4.66 (Debian)
Set-Cookie:
- ocijlj3fsmm9=5b65855c97c459db69c61861f926501f; path=/; secure; HttpOnly; SameSite=Lax,
oc_sessionPassphrase=eeaqxrN0jZy61LxpAwJ7W1RwdhNnTJ4eE53GnTsym79KnC%2Fd%2Bs7VxPDGJ8sdlqgXt84b5inOVqF2cOYkCPIXrzCHtSBkF852ekuoKW9e0oBoEgxmnsccJbD5uSRwv9h%2B;
path=/; secure; HttpOnly; SameSite=Lax, ocijlj3fsmm9=5b65855c97c459db69c61861f926501f;
path=/; secure; HttpOnly; SameSite=Lax, __Host-nc_sameSiteCookielax=true;
path=/; httponly;secure; expires=Fri, 31-Dec-2100 23:59:59 GMT; SameSite=lax,
__Host-nc_sameSiteCookiestrict=true; path=/; httponly;secure; expires=Fri,
31-Dec-2100 23:59:59 GMT; SameSite=strict, ocijlj3fsmm9=5b65855c97c459db69c61861f926501f;
path=/; secure; HttpOnly; SameSite=Lax, nc_username=deleted; expires=Thu,
01 Jan 1970 00:00:01 GMT; Max-Age=0; secure; HttpOnly, nc_token=deleted; expires=Thu,
01 Jan 1970 00:00:01 GMT; Max-Age=0; secure; HttpOnly, nc_session_id=deleted;
expires=Thu, 01 Jan 1970 00:00:01 GMT; Max-Age=0; secure; HttpOnly, nc_username=deleted;
expires=Thu, 01 Jan 1970 00:00:01 GMT; Max-Age=0; path=/; secure; HttpOnly,
nc_token=deleted; expires=Thu, 01 Jan 1970 00:00:01 GMT; Max-Age=0; path=/;
secure; HttpOnly, nc_session_id=deleted; expires=Thu, 01 Jan 1970 00:00:01
GMT; Max-Age=0; path=/; secure; HttpOnly
X-Content-Type-Options:
- nosniff
X-Frame-Options:
- SAMEORIGIN
X-Permitted-Cross-Domain-Policies:
- none
X-Powered-By:
- PHP/8.4.18
X-Request-Id:
- mpcjYVvLLOeYrNKfHGA4
X-Robots-Tag:
- noindex, nofollow
Content-Length:
- '230'
body:
encoding: UTF-8
string: |
<?xml version="1.0"?>
<ocs>
<meta>
<status>failure</status>
<statuscode>997</statuscode>
<message>Current user is not logged in</message>
<totalitems></totalitems>
<itemsperpage></itemsperpage>
</meta>
<data/>
</ocs>
recorded_at: Mon, 02 Mar 2026 16:08:46 GMT
- request:
method: post
uri: https://nextcloud.local/index.php/apps/oauth2/api/v1/token
body:
encoding: ASCII-8BIT
string: grant_type=refresh_token&scope=&refresh_token=<REFRESH_TOKEN>
headers:
User-Agent:
- OpenProject 17.2.0 HTTPX Client
Accept:
- "*/*"
Accept-Encoding:
- gzip, deflate
Authorization:
- Basic <SECRET>
Content-Type:
- application/x-www-form-urlencoded
Content-Length:
- '174'
response:
status:
code: 200
message: OK
headers:
Cache-Control:
- no-cache, no-store, must-revalidate
Content-Encoding:
- gzip
Content-Security-Policy:
- default-src 'none';base-uri 'none';manifest-src 'self';frame-ancestors 'none'
Content-Type:
- application/json; charset=utf-8
Date:
- Mon, 02 Mar 2026 16:08:46 GMT
Feature-Policy:
- autoplay 'none';camera 'none';fullscreen 'none';geolocation 'none';microphone
'none';payment 'none'
Referrer-Policy:
- no-referrer
Server:
- Apache/2.4.66 (Debian)
Set-Cookie:
- ocijlj3fsmm9=b3b43751df79bb2be113552e0da3c8ef; path=/; secure; HttpOnly; SameSite=Lax,
oc_sessionPassphrase=jjRcONBYop3Z1ttvqVG8PoNd928I2MOEQ5wvxPYxmsvSM07Ow5FKvhg1JnCFLwnYSlfMCU5FaEQ35ZGSwULlYqJ8Fndsqn5nk48zjTTdXiedpPMlZDU%2F%2FkWaYG%2BChSAK;
path=/; secure; HttpOnly; SameSite=Lax, ocijlj3fsmm9=b3b43751df79bb2be113552e0da3c8ef;
path=/; secure; HttpOnly; SameSite=Lax, __Host-nc_sameSiteCookielax=true;
path=/; httponly;secure; expires=Fri, 31-Dec-2100 23:59:59 GMT; SameSite=lax,
__Host-nc_sameSiteCookiestrict=true; path=/; httponly;secure; expires=Fri,
31-Dec-2100 23:59:59 GMT; SameSite=strict, ocijlj3fsmm9=b3b43751df79bb2be113552e0da3c8ef;
path=/; secure; HttpOnly; SameSite=Lax
X-Content-Type-Options:
- nosniff
X-Frame-Options:
- SAMEORIGIN
X-Permitted-Cross-Domain-Policies:
- none
X-Powered-By:
- PHP/8.4.18
X-Request-Id:
- JewgU7FS0QDRXDmGLnWf
X-Robots-Tag:
- noindex, nofollow
Content-Length:
- '270'
body:
encoding: UTF-8
string: '{"access_token":"<ACCESS_TOKEN>","token_type":"Bearer","expires_in":3600,"refresh_token":"<REFRESH_TOKEN>","user_id":"admin"}'
recorded_at: Mon, 02 Mar 2026 16:08:46 GMT
- request:
method: get
uri: https://nextcloud.local/ocs/v1.php/cloud/user
body:
encoding: US-ASCII
string: ''
headers:
User-Agent:
- OpenProject 17.2.0 HTTPX Client
Accept:
- "*/*"
Accept-Encoding:
- gzip, deflate
Authorization:
- Bearer <SECRET>
response:
status:
code: 200
message: OK
headers:
Cache-Control:
- no-cache, no-store, must-revalidate
Content-Encoding:
- gzip
Content-Security-Policy:
- default-src 'none';base-uri 'none';manifest-src 'self';frame-ancestors 'none'
Content-Type:
- application/xml; charset=utf-8
Date:
- Mon, 02 Mar 2026 16:08:46 GMT
Feature-Policy:
- autoplay 'none';camera 'none';fullscreen 'none';geolocation 'none';microphone
'none';payment 'none'
Referrer-Policy:
- no-referrer
Server:
- Apache/2.4.66 (Debian)
Set-Cookie:
- ocijlj3fsmm9=8c15d45120403fd0c95c1c8d23c250b1; path=/; secure; HttpOnly; SameSite=Lax,
oc_sessionPassphrase=BzSroJ7oX15ip9xi5Y8Na48QPwG3mu7fpYl%2B2wlSxOFqh%2BUEIwflqvkgtdvr7LpA5zGPXrat0VvAckP95BkTm7ZcK8JJZEwKYK7shleHPn1ZaIzThTiJWuLuuRjnmxp%2F;
path=/; secure; HttpOnly; SameSite=Lax, ocijlj3fsmm9=8c15d45120403fd0c95c1c8d23c250b1;
path=/; secure; HttpOnly; SameSite=Lax, __Host-nc_sameSiteCookielax=true;
path=/; httponly;secure; expires=Fri, 31-Dec-2100 23:59:59 GMT; SameSite=lax,
__Host-nc_sameSiteCookiestrict=true; path=/; httponly;secure; expires=Fri,
31-Dec-2100 23:59:59 GMT; SameSite=strict, ocijlj3fsmm9=8c15d45120403fd0c95c1c8d23c250b1;
path=/; secure; HttpOnly; SameSite=Lax, nc_username=deleted; expires=Thu,
01 Jan 1970 00:00:01 GMT; Max-Age=0; secure; HttpOnly, nc_token=deleted; expires=Thu,
01 Jan 1970 00:00:01 GMT; Max-Age=0; secure; HttpOnly, nc_session_id=deleted;
expires=Thu, 01 Jan 1970 00:00:01 GMT; Max-Age=0; secure; HttpOnly, nc_username=deleted;
expires=Thu, 01 Jan 1970 00:00:01 GMT; Max-Age=0; path=/; secure; HttpOnly,
nc_token=deleted; expires=Thu, 01 Jan 1970 00:00:01 GMT; Max-Age=0; path=/;
secure; HttpOnly, nc_session_id=deleted; expires=Thu, 01 Jan 1970 00:00:01
GMT; Max-Age=0; path=/; secure; HttpOnly, ocijlj3fsmm9=8c15d45120403fd0c95c1c8d23c250b1;
path=/; secure; HttpOnly; SameSite=Lax, ocijlj3fsmm9=8c15d45120403fd0c95c1c8d23c250b1;
path=/; secure; HttpOnly; SameSite=Lax, ocijlj3fsmm9=8c15d45120403fd0c95c1c8d23c250b1;
path=/; secure; HttpOnly; SameSite=Lax, ocijlj3fsmm9=8c15d45120403fd0c95c1c8d23c250b1;
path=/; secure; HttpOnly; SameSite=Lax, ocijlj3fsmm9=8c15d45120403fd0c95c1c8d23c250b1;
path=/; secure; HttpOnly; SameSite=Lax, ocijlj3fsmm9=8c15d45120403fd0c95c1c8d23c250b1;
path=/; secure; HttpOnly; SameSite=Lax
X-Content-Type-Options:
- nosniff
X-Frame-Options:
- SAMEORIGIN
X-Permitted-Cross-Domain-Policies:
- none
X-Powered-By:
- PHP/8.4.18
X-Request-Id:
- 9KY3Gces4KNzsrkeOsWU
X-Robots-Tag:
- noindex, nofollow
X-User-Id:
- admin
Content-Length:
- '691'
body:
encoding: UTF-8
string: |
<?xml version="1.0"?>
<ocs>
<meta>
<status>ok</status>
<statuscode>100</statuscode>
<message>OK</message>
<totalitems></totalitems>
<itemsperpage></itemsperpage>
</meta>
<data>
<enabled>1</enabled>
<storageLocation>/var/www/html/data/admin</storageLocation>
<id>admin</id>
<firstLoginTimestamp>1772444624</firstLoginTimestamp>
<lastLoginTimestamp>1772467726</lastLoginTimestamp>
<lastLogin>1772467726000</lastLogin>
<backend>Database</backend>
<subadmin/>
<quota>
<free>-3</free>
<used>60872728</used>
<total>-3</total>
<relative>0</relative>
<quota>-3</quota>
</quota>
<manager></manager>
<avatarScope>v2-federated</avatarScope>
<email/>
<emailScope>v2-federated</emailScope>
<additional_mail/>
<additional_mailScope/>
<displayname>admin</displayname>
<display-name>admin</display-name>
<displaynameScope>v2-federated</displaynameScope>
<phone></phone>
<phoneScope>v2-local</phoneScope>
<address></address>
<addressScope>v2-local</addressScope>
<website></website>
<websiteScope>v2-local</websiteScope>
<twitter></twitter>
<twitterScope>v2-local</twitterScope>
<bluesky></bluesky>
<blueskyScope>v2-local</blueskyScope>
<fediverse></fediverse>
<fediverseScope>v2-local</fediverseScope>
<organisation></organisation>
<organisationScope>v2-local</organisationScope>
<role></role>
<roleScope>v2-local</roleScope>
<headline></headline>
<headlineScope>v2-local</headlineScope>
<biography></biography>
<biographyScope>v2-local</biographyScope>
<profile_enabled>1</profile_enabled>
<profile_enabledScope>v2-local</profile_enabledScope>
<pronouns></pronouns>
<pronounsScope>v2-federated</pronounsScope>
<groups>
<element>admin</element>
</groups>
<language>en</language>
<locale></locale>
<timezone>Europe/Busingen</timezone>
<notify_email/>
<backendCapabilities>
<setDisplayName>1</setDisplayName>
<setPassword>1</setPassword>
</backendCapabilities>
</data>
</ocs>
recorded_at: Mon, 02 Mar 2026 16:08:46 GMT
- request:
method: get
uri: https://nextcloud.local/ocs/v1.php/cloud/user
body:
encoding: US-ASCII
string: ''
headers:
User-Agent:
- OpenProject 17.2.0 HTTPX Client
Accept:
- "*/*"
Accept-Encoding:
- gzip, deflate
Authorization:
- Bearer <SECRET>
response:
status:
code: 200
message: OK
headers:
Cache-Control:
- no-cache, no-store, must-revalidate
Content-Encoding:
- gzip
Content-Security-Policy:
- default-src 'none';base-uri 'none';manifest-src 'self';frame-ancestors 'none'
Content-Type:
- application/xml; charset=utf-8
Date:
- Mon, 02 Mar 2026 16:08:46 GMT
Feature-Policy:
- autoplay 'none';camera 'none';fullscreen 'none';geolocation 'none';microphone
'none';payment 'none'
Referrer-Policy:
- no-referrer
Server:
- Apache/2.4.66 (Debian)
Set-Cookie:
- ocijlj3fsmm9=8c15d45120403fd0c95c1c8d23c250b1; path=/; secure; HttpOnly; SameSite=Lax,
oc_sessionPassphrase=BzSroJ7oX15ip9xi5Y8Na48QPwG3mu7fpYl%2B2wlSxOFqh%2BUEIwflqvkgtdvr7LpA5zGPXrat0VvAckP95BkTm7ZcK8JJZEwKYK7shleHPn1ZaIzThTiJWuLuuRjnmxp%2F;
path=/; secure; HttpOnly; SameSite=Lax, ocijlj3fsmm9=8c15d45120403fd0c95c1c8d23c250b1;
path=/; secure; HttpOnly; SameSite=Lax, __Host-nc_sameSiteCookielax=true;
path=/; httponly;secure; expires=Fri, 31-Dec-2100 23:59:59 GMT; SameSite=lax,
__Host-nc_sameSiteCookiestrict=true; path=/; httponly;secure; expires=Fri,
31-Dec-2100 23:59:59 GMT; SameSite=strict, ocijlj3fsmm9=8c15d45120403fd0c95c1c8d23c250b1;
path=/; secure; HttpOnly; SameSite=Lax, nc_username=deleted; expires=Thu,
01 Jan 1970 00:00:01 GMT; Max-Age=0; secure; HttpOnly, nc_token=deleted; expires=Thu,
01 Jan 1970 00:00:01 GMT; Max-Age=0; secure; HttpOnly, nc_session_id=deleted;
expires=Thu, 01 Jan 1970 00:00:01 GMT; Max-Age=0; secure; HttpOnly, nc_username=deleted;
expires=Thu, 01 Jan 1970 00:00:01 GMT; Max-Age=0; path=/; secure; HttpOnly,
nc_token=deleted; expires=Thu, 01 Jan 1970 00:00:01 GMT; Max-Age=0; path=/;
secure; HttpOnly, nc_session_id=deleted; expires=Thu, 01 Jan 1970 00:00:01
GMT; Max-Age=0; path=/; secure; HttpOnly, ocijlj3fsmm9=8c15d45120403fd0c95c1c8d23c250b1;
path=/; secure; HttpOnly; SameSite=Lax, ocijlj3fsmm9=8c15d45120403fd0c95c1c8d23c250b1;
path=/; secure; HttpOnly; SameSite=Lax, ocijlj3fsmm9=8c15d45120403fd0c95c1c8d23c250b1;
path=/; secure; HttpOnly; SameSite=Lax, ocijlj3fsmm9=8c15d45120403fd0c95c1c8d23c250b1;
path=/; secure; HttpOnly; SameSite=Lax, ocijlj3fsmm9=8c15d45120403fd0c95c1c8d23c250b1;
path=/; secure; HttpOnly; SameSite=Lax, ocijlj3fsmm9=8c15d45120403fd0c95c1c8d23c250b1;
path=/; secure; HttpOnly; SameSite=Lax
X-Content-Type-Options:
- nosniff
X-Frame-Options:
- SAMEORIGIN
X-Permitted-Cross-Domain-Policies:
- none
X-Powered-By:
- PHP/8.4.18
X-Request-Id:
- 9KY3Gces4KNzsrkeOsWU
X-Robots-Tag:
- noindex, nofollow
X-User-Id:
- admin
Content-Length:
- '691'
body:
encoding: UTF-8
string: |
<?xml version="1.0"?>
<ocs>
<meta>
<status>ok</status>
<statuscode>100</statuscode>
<message>OK</message>
<totalitems></totalitems>
<itemsperpage></itemsperpage>
</meta>
<data>
<enabled>1</enabled>
<storageLocation>/var/www/html/data/admin</storageLocation>
<id>admin</id>
<firstLoginTimestamp>1772444624</firstLoginTimestamp>
<lastLoginTimestamp>1772467726</lastLoginTimestamp>
<lastLogin>1772467726000</lastLogin>
<backend>Database</backend>
<subadmin/>
<quota>
<free>-3</free>
<used>60872728</used>
<total>-3</total>
<relative>0</relative>
<quota>-3</quota>
</quota>
<manager></manager>
<avatarScope>v2-federated</avatarScope>
<email/>
<emailScope>v2-federated</emailScope>
<additional_mail/>
<additional_mailScope/>
<displayname>admin</displayname>
<display-name>admin</display-name>
<displaynameScope>v2-federated</displaynameScope>
<phone></phone>
<phoneScope>v2-local</phoneScope>
<address></address>
<addressScope>v2-local</addressScope>
<website></website>
<websiteScope>v2-local</websiteScope>
<twitter></twitter>
<twitterScope>v2-local</twitterScope>
<bluesky></bluesky>
<blueskyScope>v2-local</blueskyScope>
<fediverse></fediverse>
<fediverseScope>v2-local</fediverseScope>
<organisation></organisation>
<organisationScope>v2-local</organisationScope>
<role></role>
<roleScope>v2-local</roleScope>
<headline></headline>
<headlineScope>v2-local</headlineScope>
<biography></biography>
<biographyScope>v2-local</biographyScope>
<profile_enabled>1</profile_enabled>
<profile_enabledScope>v2-local</profile_enabledScope>
<pronouns></pronouns>
<pronounsScope>v2-federated</pronounsScope>
<groups>
<element>admin</element>
</groups>
<language>en</language>
<locale></locale>
<timezone>Europe/Busingen</timezone>
<notify_email/>
<backendCapabilities>
<setDisplayName>1</setDisplayName>
<setPassword>1</setPassword>
</backendCapabilities>
</data>
</ocs>
recorded_at: Mon, 02 Mar 2026 16:08:46 GMT
recorded_with: VCR 6.4.0