Commit 1e7ff15d authored by Yao Xiao's avatar Yao Xiao Committed by Chromium LUCI CQ

Implement PrivacySandboxSettings::FlocDataAccessibleSince()

Bug: 1152336, 1155057
Change-Id: Ib2d73bfdb0b977f6227d3af0069169cdf85f704c
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/2593027
Commit-Queue: Yao Xiao <yaoxia@chromium.org>
Reviewed-by: default avatarMartin Šrámek <msramek@chromium.org>
Cr-Commit-Position: refs/heads/master@{#837405}
parent 4811854b
...@@ -69,6 +69,8 @@ ...@@ -69,6 +69,8 @@
#include "chrome/browser/prefetch/search_prefetch/search_prefetch_service_factory.h" #include "chrome/browser/prefetch/search_prefetch/search_prefetch_service_factory.h"
#include "chrome/browser/previews/previews_service.h" #include "chrome/browser/previews/previews_service.h"
#include "chrome/browser/previews/previews_service_factory.h" #include "chrome/browser/previews/previews_service_factory.h"
#include "chrome/browser/privacy_sandbox/privacy_sandbox_settings.h"
#include "chrome/browser/privacy_sandbox/privacy_sandbox_settings_factory.h"
#include "chrome/browser/profiles/profile.h" #include "chrome/browser/profiles/profile.h"
#include "chrome/browser/safe_browsing/safe_browsing_service.h" #include "chrome/browser/safe_browsing/safe_browsing_service.h"
#include "chrome/browser/search_engines/template_url_service_factory.h" #include "chrome/browser/search_engines/template_url_service_factory.h"
...@@ -680,6 +682,11 @@ void ChromeBrowsingDataRemoverDelegate::RemoveEmbedderData( ...@@ -680,6 +682,11 @@ void ChromeBrowsingDataRemoverDelegate::RemoveEmbedderData(
if (filter_builder->GetMode() == if (filter_builder->GetMode() ==
BrowsingDataFilterBuilder::Mode::kPreserve) { BrowsingDataFilterBuilder::Mode::kPreserve) {
PrivacySandboxSettings* privacy_sandbox_settings =
PrivacySandboxSettingsFactory::GetForProfile(profile_);
if (privacy_sandbox_settings)
privacy_sandbox_settings->OnCookiesCleared();
MediaDeviceIDSalt::Reset(profile_->GetPrefs()); MediaDeviceIDSalt::Reset(profile_->GetPrefs());
#if defined(OS_ANDROID) #if defined(OS_ANDROID)
......
...@@ -8,6 +8,7 @@ ...@@ -8,6 +8,7 @@
#include "base/time/time.h" #include "base/time/time.h"
#include "chrome/common/chrome_features.h" #include "chrome/common/chrome_features.h"
#include "components/content_settings/core/browser/cookie_settings.h" #include "components/content_settings/core/browser/cookie_settings.h"
#include "components/content_settings/core/browser/host_content_settings_map.h"
#include "components/prefs/pref_service.h" #include "components/prefs/pref_service.h"
#include "components/privacy_sandbox/privacy_sandbox_prefs.h" #include "components/privacy_sandbox/privacy_sandbox_prefs.h"
#include "url/gurl.h" #include "url/gurl.h"
...@@ -15,6 +16,12 @@ ...@@ -15,6 +16,12 @@
namespace { namespace {
bool IsCookiesClearOnExitEnabled(HostContentSettingsMap* map) {
return map->GetDefaultContentSetting(ContentSettingsType::COOKIES,
/*provider_id=*/nullptr) ==
ContentSetting::CONTENT_SETTING_SESSION_ONLY;
}
bool HasNonDefaultBlockSetting(const ContentSettingsForOneType& cookie_settings, bool HasNonDefaultBlockSetting(const ContentSettingsForOneType& cookie_settings,
const GURL& url, const GURL& url,
const GURL& top_frame_origin) { const GURL& top_frame_origin) {
...@@ -51,13 +58,26 @@ bool HasNonDefaultBlockSetting(const ContentSettingsForOneType& cookie_settings, ...@@ -51,13 +58,26 @@ bool HasNonDefaultBlockSetting(const ContentSettingsForOneType& cookie_settings,
} // namespace } // namespace
PrivacySandboxSettings::PrivacySandboxSettings( PrivacySandboxSettings::PrivacySandboxSettings(
HostContentSettingsMap* host_content_settings_map,
content_settings::CookieSettings* cookie_settings, content_settings::CookieSettings* cookie_settings,
PrefService* pref_service) PrefService* pref_service)
: cookie_settings_(cookie_settings), pref_service_(pref_service) { : host_content_settings_map_(host_content_settings_map),
cookie_settings_(cookie_settings),
pref_service_(pref_service) {
DCHECK(pref_service_); DCHECK(pref_service_);
DCHECK(host_content_settings_map_);
DCHECK(cookie_settings_); DCHECK(cookie_settings_);
// "Clear on exit" causes a cookie deletion on shutdown. But for practical
// purposes, we're notifying the observers on startup (which should be
// equivalent, as no cookie operations could have happened while the profile
// was shut down).
if (IsCookiesClearOnExitEnabled(host_content_settings_map_))
OnCookiesCleared();
} }
PrivacySandboxSettings::~PrivacySandboxSettings() = default;
bool PrivacySandboxSettings::IsFlocAllowed( bool PrivacySandboxSettings::IsFlocAllowed(
const GURL& url, const GURL& url,
const base::Optional<url::Origin>& top_frame_origin) const { const base::Optional<url::Origin>& top_frame_origin) const {
...@@ -68,9 +88,7 @@ bool PrivacySandboxSettings::IsFlocAllowed( ...@@ -68,9 +88,7 @@ bool PrivacySandboxSettings::IsFlocAllowed(
} }
base::Time PrivacySandboxSettings::FlocDataAccessibleSince() const { base::Time PrivacySandboxSettings::FlocDataAccessibleSince() const {
// Simply indicate that all history is available. return pref_service_->GetTime(prefs::kPrivacySandboxFlocDataAccessibleSince);
// TODO(crbug.com/1152336): Respect clear on exit & storage deletion events.
return base::Time();
} }
bool PrivacySandboxSettings::IsConversionMeasurementAllowed( bool PrivacySandboxSettings::IsConversionMeasurementAllowed(
...@@ -102,6 +120,23 @@ bool PrivacySandboxSettings::ShouldSendConversionReport( ...@@ -102,6 +120,23 @@ bool PrivacySandboxSettings::ShouldSendConversionReport(
cookie_settings); cookie_settings);
} }
void PrivacySandboxSettings::OnCookiesCleared() {
pref_service_->SetTime(prefs::kPrivacySandboxFlocDataAccessibleSince,
base::Time::Now());
for (auto& observer : observers_) {
observer.OnFlocDataAccessibleSinceUpdated();
}
}
void PrivacySandboxSettings::AddObserver(Observer* observer) {
observers_.AddObserver(observer);
}
void PrivacySandboxSettings::RemoveObserver(Observer* observer) {
observers_.RemoveObserver(observer);
}
bool PrivacySandboxSettings::IsPrivacySandboxAllowed( bool PrivacySandboxSettings::IsPrivacySandboxAllowed(
const GURL& url, const GURL& url,
const base::Optional<url::Origin>& top_frame_origin, const base::Optional<url::Origin>& top_frame_origin,
......
...@@ -6,12 +6,14 @@ ...@@ -6,12 +6,14 @@
#define CHROME_BROWSER_PRIVACY_SANDBOX_PRIVACY_SANDBOX_SETTINGS_H_ #define CHROME_BROWSER_PRIVACY_SANDBOX_PRIVACY_SANDBOX_SETTINGS_H_
#include "base/memory/ref_counted.h" #include "base/memory/ref_counted.h"
#include "base/observer_list.h"
#include "base/optional.h" #include "base/optional.h"
#include "base/time/time.h" #include "base/time/time.h"
#include "components/content_settings/core/common/content_settings.h" #include "components/content_settings/core/common/content_settings.h"
#include "components/keyed_service/core/keyed_service.h" #include "components/keyed_service/core/keyed_service.h"
#include "net/cookies/cookie_constants.h" #include "net/cookies/cookie_constants.h"
class HostContentSettingsMap;
class PrefService; class PrefService;
namespace content_settings { namespace content_settings {
...@@ -29,9 +31,17 @@ class Origin; ...@@ -29,9 +31,17 @@ class Origin;
// components. // components.
class PrivacySandboxSettings : public KeyedService { class PrivacySandboxSettings : public KeyedService {
public: public:
PrivacySandboxSettings(content_settings::CookieSettings* cookie_settings, class Observer {
public:
virtual void OnFlocDataAccessibleSinceUpdated() = 0;
};
PrivacySandboxSettings(HostContentSettingsMap* host_content_settings_map,
content_settings::CookieSettings* cookie_settings,
PrefService* prefs); PrefService* prefs);
~PrivacySandboxSettings() override;
// Determines whether FLoC is allowable in a particular context. // Determines whether FLoC is allowable in a particular context.
// |top_frame_origin| is used to check for content settings which could both // |top_frame_origin| is used to check for content settings which could both
// affect 1P and 3P contexts. // affect 1P and 3P contexts.
...@@ -60,6 +70,14 @@ class PrivacySandboxSettings : public KeyedService { ...@@ -60,6 +70,14 @@ class PrivacySandboxSettings : public KeyedService {
const url::Origin& conversion_origin, const url::Origin& conversion_origin,
const url::Origin& reporting_origin) const; const url::Origin& reporting_origin) const;
// Called when there's a broad cookies clearing action. For example, this
// should be called on "Clear browsing data", but shouldn't be called on the
// Clear-Site-Data header, as it's restricted to a specific site.
void OnCookiesCleared();
void AddObserver(Observer* observer);
void RemoveObserver(Observer* observer);
protected: protected:
// Determines based on the current features, preferences and provided // Determines based on the current features, preferences and provided
// |cookie_settings| whether Privacy Sandbox APIs are generally allowable for // |cookie_settings| whether Privacy Sandbox APIs are generally allowable for
...@@ -72,6 +90,9 @@ class PrivacySandboxSettings : public KeyedService { ...@@ -72,6 +90,9 @@ class PrivacySandboxSettings : public KeyedService {
const ContentSettingsForOneType& cookie_settings) const; const ContentSettingsForOneType& cookie_settings) const;
private: private:
base::ObserverList<Observer>::Unchecked observers_;
HostContentSettingsMap* host_content_settings_map_;
content_settings::CookieSettings* cookie_settings_; content_settings::CookieSettings* cookie_settings_;
PrefService* pref_service_; PrefService* pref_service_;
}; };
......
// Copyright 2020 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include "base/strings/strcat.h"
#include "base/test/bind.h"
#include "chrome/browser/browsing_data/chrome_browsing_data_remover_constants.h"
#include "chrome/browser/privacy_sandbox/privacy_sandbox_settings.h"
#include "chrome/browser/privacy_sandbox/privacy_sandbox_settings_factory.h"
#include "chrome/browser/profiles/profile.h"
#include "chrome/browser/ui/browser.h"
#include "chrome/test/base/in_process_browser_test.h"
#include "chrome/test/base/ui_test_utils.h"
#include "content/public/browser/browser_context.h"
#include "content/public/test/browser_test.h"
#include "content/public/test/browser_test_utils.h"
#include "content/public/test/browsing_data_remover_test_util.h"
#include "content/public/test/test_host_resolver.h"
#include "net/dns/mock_host_resolver.h"
#include "net/test/embedded_test_server/http_request.h"
#include "net/test/embedded_test_server/http_response.h"
namespace {
class FlocDataAccessibleSinceUpdateObserver
: public PrivacySandboxSettings::Observer {
public:
void OnFlocDataAccessibleSinceUpdated() override { update_seen_ = true; }
bool update_seen() const { return update_seen_; }
private:
bool update_seen_ = false;
};
} // namespace
class PrivacySandboxSettingsBrowserTest : public InProcessBrowserTest {
public:
PrivacySandboxSettingsBrowserTest() = default;
void SetUpOnMainThread() override {
host_resolver()->AddRule("*", "127.0.0.1");
https_server_.SetSSLConfig(net::EmbeddedTestServer::CERT_TEST_NAMES);
https_server_.AddDefaultHandlers(GetChromeTestDataDir());
https_server_.RegisterRequestHandler(
base::BindRepeating(&PrivacySandboxSettingsBrowserTest::HandleRequest,
base::Unretained(this)));
content::SetupCrossSiteRedirector(&https_server_);
ASSERT_TRUE(https_server_.Start());
}
std::unique_ptr<net::test_server::HttpResponse> HandleRequest(
const net::test_server::HttpRequest& request) {
const GURL& url = request.GetURL();
if (url.path() == "/clear_site_data_header_cookies") {
auto response = std::make_unique<net::test_server::BasicHttpResponse>();
response->AddCustomHeader("Clear-Site-Data", "\"cookies\"");
response->set_code(net::HTTP_OK);
response->set_content_type("text/html");
response->set_content(std::string());
return std::move(response);
}
// Use the default handler for unrelated requests.
return nullptr;
}
void ClearAllCookies() {
content::BrowsingDataRemover* remover =
content::BrowserContext::GetBrowsingDataRemover(browser()->profile());
content::BrowsingDataRemoverCompletionObserver observer(remover);
remover->RemoveAndReply(
base::Time(), base::Time::Max(),
content::BrowsingDataRemover::DATA_TYPE_COOKIES,
content::BrowsingDataRemover::ORIGIN_TYPE_UNPROTECTED_WEB, &observer);
observer.BlockUntilCompletion();
}
PrivacySandboxSettings* privacy_sandbox_settings() {
return PrivacySandboxSettingsFactory::GetForProfile(browser()->profile());
}
content::WebContents* web_contents() {
return browser()->tab_strip_model()->GetActiveWebContents();
}
protected:
net::EmbeddedTestServer https_server_{
net::test_server::EmbeddedTestServer::TYPE_HTTPS};
};
// Test that cookie clearings triggered by "Clear browsing data" will trigger
// an update to floc-data-accessible-since and invoke the corresponding observer
// method.
IN_PROC_BROWSER_TEST_F(PrivacySandboxSettingsBrowserTest, ClearAllCookies) {
EXPECT_EQ(base::Time(),
privacy_sandbox_settings()->FlocDataAccessibleSince());
FlocDataAccessibleSinceUpdateObserver observer;
privacy_sandbox_settings()->AddObserver(&observer);
ClearAllCookies();
EXPECT_NE(base::Time(),
privacy_sandbox_settings()->FlocDataAccessibleSince());
EXPECT_TRUE(observer.update_seen());
}
// Test that cookie clearings triggered by Clear-Site-Data header won't trigger
// an update to floc-data-accessible-since or invoke the corresponding observer
// method.
IN_PROC_BROWSER_TEST_F(PrivacySandboxSettingsBrowserTest,
ClearSiteDataCookies) {
EXPECT_EQ(base::Time(),
privacy_sandbox_settings()->FlocDataAccessibleSince());
FlocDataAccessibleSinceUpdateObserver observer;
privacy_sandbox_settings()->AddObserver(&observer);
ui_test_utils::NavigateToURL(
browser(),
https_server_.GetURL("a.test", "/clear_site_data_header_cookies"));
EXPECT_EQ(base::Time(),
privacy_sandbox_settings()->FlocDataAccessibleSince());
EXPECT_FALSE(observer.update_seen());
}
...@@ -6,6 +6,7 @@ ...@@ -6,6 +6,7 @@
#include "base/memory/singleton.h" #include "base/memory/singleton.h"
#include "chrome/browser/content_settings/cookie_settings_factory.h" #include "chrome/browser/content_settings/cookie_settings_factory.h"
#include "chrome/browser/content_settings/host_content_settings_map_factory.h"
#include "chrome/browser/privacy_sandbox/privacy_sandbox_settings.h" #include "chrome/browser/privacy_sandbox/privacy_sandbox_settings.h"
#include "chrome/browser/profiles/incognito_helpers.h" #include "chrome/browser/profiles/incognito_helpers.h"
#include "chrome/browser/profiles/profile.h" #include "chrome/browser/profiles/profile.h"
...@@ -27,6 +28,7 @@ PrivacySandboxSettingsFactory::PrivacySandboxSettingsFactory() ...@@ -27,6 +28,7 @@ PrivacySandboxSettingsFactory::PrivacySandboxSettingsFactory()
: BrowserContextKeyedServiceFactory( : BrowserContextKeyedServiceFactory(
"PrivacySandboxSettings", "PrivacySandboxSettings",
BrowserContextDependencyManager::GetInstance()) { BrowserContextDependencyManager::GetInstance()) {
DependsOn(HostContentSettingsMapFactory::GetInstance());
DependsOn(CookieSettingsFactory::GetInstance()); DependsOn(CookieSettingsFactory::GetInstance());
} }
...@@ -35,6 +37,7 @@ KeyedService* PrivacySandboxSettingsFactory::BuildServiceInstanceFor( ...@@ -35,6 +37,7 @@ KeyedService* PrivacySandboxSettingsFactory::BuildServiceInstanceFor(
Profile* profile = Profile::FromBrowserContext(context); Profile* profile = Profile::FromBrowserContext(context);
return new PrivacySandboxSettings( return new PrivacySandboxSettings(
HostContentSettingsMapFactory::GetForProfile(profile),
CookieSettingsFactory::GetForProfile(profile).get(), profile->GetPrefs()); CookieSettingsFactory::GetForProfile(profile).get(), profile->GetPrefs());
} }
......
...@@ -6,6 +6,7 @@ ...@@ -6,6 +6,7 @@
#include "base/test/gtest_util.h" #include "base/test/gtest_util.h"
#include "base/test/scoped_feature_list.h" #include "base/test/scoped_feature_list.h"
#include "base/util/values/values_util.h"
#include "chrome/browser/content_settings/cookie_settings_factory.h" #include "chrome/browser/content_settings/cookie_settings_factory.h"
#include "chrome/browser/content_settings/host_content_settings_map_factory.h" #include "chrome/browser/content_settings/host_content_settings_map_factory.h"
#include "chrome/common/chrome_features.h" #include "chrome/common/chrome_features.h"
...@@ -36,14 +37,21 @@ struct CookieContentSettingException { ...@@ -36,14 +37,21 @@ struct CookieContentSettingException {
class PrivacySandboxSettingsTest : public testing::Test { class PrivacySandboxSettingsTest : public testing::Test {
public: public:
PrivacySandboxSettingsTest() = default; PrivacySandboxSettingsTest()
: browser_task_environment_(
base::test::TaskEnvironment::TimeSource::MOCK_TIME) {}
void SetUp() override { void SetUp() override {
InitializePrefsBeforeStart();
privacy_sandbox_settings_ = std::make_unique<PrivacySandboxSettings>( privacy_sandbox_settings_ = std::make_unique<PrivacySandboxSettings>(
HostContentSettingsMapFactory::GetForProfile(profile()),
CookieSettingsFactory::GetForProfile(profile()).get(), CookieSettingsFactory::GetForProfile(profile()).get(),
profile()->GetPrefs()); profile()->GetPrefs());
} }
virtual void InitializePrefsBeforeStart() {}
// Sets up preferences and content settings based on provided parameters. // Sets up preferences and content settings based on provided parameters.
void SetupTestState( void SetupTestState(
bool privacy_sandbox_available, bool privacy_sandbox_available,
...@@ -435,3 +443,53 @@ TEST_F(PrivacySandboxSettingsTest, ThirdPartyByDefault) { ...@@ -435,3 +443,53 @@ TEST_F(PrivacySandboxSettingsTest, ThirdPartyByDefault) {
url::Origin::Create(GURL("https://embedded.com")), url::Origin::Create(GURL("https://embedded.com")),
url::Origin::Create(GURL("https://embedded.com")))); url::Origin::Create(GURL("https://embedded.com"))));
} }
TEST_F(PrivacySandboxSettingsTest, FlocDataAccessibleSince) {
ASSERT_NE(base::Time(), base::Time::Now());
EXPECT_EQ(base::Time(),
privacy_sandbox_settings()->FlocDataAccessibleSince());
privacy_sandbox_settings()->OnCookiesCleared();
EXPECT_EQ(base::Time::Now(),
privacy_sandbox_settings()->FlocDataAccessibleSince());
}
class PrivacySandboxSettingsTestCookiesClearOnExitTurnedOff
: public PrivacySandboxSettingsTest {
public:
void InitializePrefsBeforeStart() override {
profile()->GetTestingPrefService()->SetUserPref(
prefs::kPrivacySandboxFlocDataAccessibleSince,
std::make_unique<base::Value>(
::util::TimeToValue(base::Time::FromTimeT(12345))));
}
};
TEST_F(PrivacySandboxSettingsTestCookiesClearOnExitTurnedOff,
UseLastFlocDataAccessibleSince) {
EXPECT_EQ(base::Time::FromTimeT(12345),
privacy_sandbox_settings()->FlocDataAccessibleSince());
}
class PrivacySandboxSettingsTestCookiesClearOnExitTurnedOn
: public PrivacySandboxSettingsTest {
public:
void InitializePrefsBeforeStart() override {
auto* map = HostContentSettingsMapFactory::GetForProfile(profile());
map->SetDefaultContentSetting(ContentSettingsType::COOKIES,
ContentSetting::CONTENT_SETTING_SESSION_ONLY);
profile()->GetTestingPrefService()->SetUserPref(
prefs::kPrivacySandboxFlocDataAccessibleSince,
std::make_unique<base::Value>(
::util::TimeToValue(base::Time::FromTimeT(12345))));
}
};
TEST_F(PrivacySandboxSettingsTestCookiesClearOnExitTurnedOn,
UpdateFlocDataAccessibleSince) {
EXPECT_EQ(base::Time::Now(),
privacy_sandbox_settings()->FlocDataAccessibleSince());
}
...@@ -1283,6 +1283,7 @@ if (!is_android) { ...@@ -1283,6 +1283,7 @@ if (!is_android) {
"../browser/previews/previews_test_util.cc", "../browser/previews/previews_test_util.cc",
"../browser/previews/previews_test_util.h", "../browser/previews/previews_test_util.h",
"../browser/previews/resource_loading_hints/resource_loading_hints_browsertest.cc", "../browser/previews/resource_loading_hints/resource_loading_hints_browsertest.cc",
"../browser/privacy_sandbox/privacy_sandbox_settings_browsertest.cc",
"../browser/profile_resetter/profile_resetter_browsertest.cc", "../browser/profile_resetter/profile_resetter_browsertest.cc",
"../browser/profiles/host_zoom_map_browsertest.cc", "../browser/profiles/host_zoom_map_browsertest.cc",
"../browser/profiles/profile_activity_metrics_recorder_browsertest.cc", "../browser/profiles/profile_activity_metrics_recorder_browsertest.cc",
......
...@@ -18,6 +18,9 @@ const char kPrivacySandboxManuallyControlled[] = ...@@ -18,6 +18,9 @@ const char kPrivacySandboxManuallyControlled[] =
const char kPrivacySandboxPreferencesReconciled[] = const char kPrivacySandboxPreferencesReconciled[] =
"privacy_sandbox.preferences_reconciled"; "privacy_sandbox.preferences_reconciled";
const char kPrivacySandboxFlocDataAccessibleSince[] =
"privacy_sandbox.floc_data_accessible_since";
} // namespace prefs } // namespace prefs
namespace privacy_sandbox { namespace privacy_sandbox {
...@@ -31,6 +34,8 @@ void RegisterProfilePrefs(PrefRegistrySimple* registry) { ...@@ -31,6 +34,8 @@ void RegisterProfilePrefs(PrefRegistrySimple* registry) {
user_prefs::PrefRegistrySyncable::SYNCABLE_PREF); user_prefs::PrefRegistrySyncable::SYNCABLE_PREF);
registry->RegisterBooleanPref(prefs::kPrivacySandboxPreferencesReconciled, registry->RegisterBooleanPref(prefs::kPrivacySandboxPreferencesReconciled,
false); false);
registry->RegisterTimePref(prefs::kPrivacySandboxFlocDataAccessibleSince,
base::Time());
} }
} // namespace privacy_sandbox } // namespace privacy_sandbox
...@@ -24,6 +24,10 @@ extern const char kPrivacySandboxManuallyControlled[]; ...@@ -24,6 +24,10 @@ extern const char kPrivacySandboxManuallyControlled[];
// enabled. // enabled.
extern const char kPrivacySandboxPreferencesReconciled[]; extern const char kPrivacySandboxPreferencesReconciled[];
// The point in time from which history is eligible to be used when calculating
// a user's FLoC ID.
extern const char kPrivacySandboxFlocDataAccessibleSince[];
} // namespace prefs } // namespace prefs
namespace privacy_sandbox { namespace privacy_sandbox {
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment