Commit 23c719db authored by Ken Rockot's avatar Ken Rockot Committed by Commit Bot

Remove more mojom dependencies from DOM Storage

Eliminates use of the BatchedOperation mojom struct from
LocalStorageContextMojo.

Bug: 1000959
Change-Id: I09b849a3a53379735535dc7d00db19d2c745bfe2
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/1863253
Commit-Queue: Ken Rockot <rockot@google.com>
Reviewed-by: default avatarMarijn Kruisselbrink <mek@chromium.org>
Reviewed-by: default avatarChris Mumford <cmumford@google.com>
Cr-Commit-Position: refs/heads/master@{#706633}
parent 9e7bbb13
......@@ -19,6 +19,7 @@
#include "base/single_thread_task_runner.h"
#include "base/stl_util.h"
#include "base/strings/string_number_conversions.h"
#include "base/strings/string_piece.h"
#include "base/strings/stringprintf.h"
#include "base/system/sys_info.h"
#include "base/task/post_task.h"
......@@ -57,9 +58,7 @@ namespace content {
namespace {
const char kVersionKey[] = "VERSION";
const char kOriginSeparator = '\x00';
const char kDataPrefix[] = "_";
constexpr base::StringPiece kVersionKey = "VERSION";
const uint8_t kMetaPrefix[] = {'M', 'E', 'T', 'A', ':'};
const int64_t kMinSchemaVersion = 1;
const int64_t kCurrentLocalStorageSchemaVersion = 1;
......@@ -81,9 +80,9 @@ const size_t kMaxLocalStorageCacheSize = 20 * 1024 * 1024;
static const uint8_t kUTF16Format = 0;
static const uint8_t kLatin1Format = 1;
std::vector<uint8_t> CreateMetaDataKey(const url::Origin& origin) {
storage::DomStorageDatabase::Key CreateMetaDataKey(const url::Origin& origin) {
auto serialized_origin = leveldb::StdStringToUint8Vector(origin.Serialize());
std::vector<uint8_t> key;
storage::DomStorageDatabase::Key key;
key.reserve(base::size(kMetaPrefix) + serialized_origin.size());
key.insert(key.end(), kMetaPrefix, kMetaPrefix + base::size(kMetaPrefix));
key.insert(key.end(), serialized_origin.begin(), serialized_origin.end());
......@@ -122,20 +121,36 @@ void CallMigrationCalback(StorageAreaImpl::ValueMapCallback callback,
std::move(callback).Run(std::move(data));
}
void AddDeleteOriginOperations(
std::vector<leveldb::mojom::BatchedOperationPtr>* operations,
const url::Origin& origin) {
leveldb::mojom::BatchedOperationPtr item =
leveldb::mojom::BatchedOperation::New();
item->type = leveldb::mojom::BatchOperationType::DELETE_PREFIXED_KEY;
item->key = leveldb::StdStringToUint8Vector(kDataPrefix + origin.Serialize() +
kOriginSeparator);
operations->push_back(std::move(item));
item = leveldb::mojom::BatchedOperation::New();
item->type = leveldb::mojom::BatchOperationType::DELETE_KEY;
item->key = CreateMetaDataKey(origin);
operations->push_back(std::move(item));
storage::DomStorageDatabase::Key MakeOriginPrefix(const url::Origin& origin) {
const char kDataPrefix = '_';
const std::string serialized_origin = origin.Serialize();
const char kOriginSeparator = '\x00';
storage::DomStorageDatabase::Key prefix;
prefix.reserve(serialized_origin.size() + 2);
prefix.push_back(kDataPrefix);
prefix.insert(prefix.end(), serialized_origin.begin(),
serialized_origin.end());
prefix.push_back(kOriginSeparator);
return prefix;
}
void DeleteOrigins(leveldb::LevelDBDatabaseImpl* database,
std::vector<url::Origin> origins,
base::OnceCallback<void(leveldb::Status)> callback) {
database->RunDatabaseTask(
base::BindOnce(
[](std::vector<url::Origin> origins,
const storage::DomStorageDatabase& db) {
leveldb::WriteBatch batch;
for (const auto& origin : origins) {
db.DeletePrefixed(MakeOriginPrefix(origin), &batch);
batch.Delete(leveldb_env::MakeSlice(CreateMetaDataKey(origin)));
}
return db.Commit(&batch);
},
std::move(origins)),
std::move(callback));
}
enum class CachePurgeReason {
......@@ -208,8 +223,7 @@ class LocalStorageContextMojo::StorageAreaHolder final
}
#endif
area_ = std::make_unique<StorageAreaImpl>(
context_->database_.get(),
kDataPrefix + origin_.Serialize() + kOriginSeparator, this, options);
context_->database_.get(), MakeOriginPrefix(origin_), this, options);
area_ptr_ = area_.get();
}
......@@ -223,49 +237,45 @@ class LocalStorageContextMojo::StorageAreaHolder final
storage_area()->ScheduleImmediateCommit();
}
std::vector<leveldb::mojom::BatchedOperationPtr> PrepareToCommit() override {
std::vector<leveldb::mojom::BatchedOperationPtr> operations;
void PrepareToCommit(std::vector<storage::DomStorageDatabase::KeyValuePair>*
extra_entries_to_add,
std::vector<storage::DomStorageDatabase::Key>*
extra_keys_to_delete) override {
// Write schema version if not already done so before.
if (!context_->database_initialized_) {
leveldb::mojom::BatchedOperationPtr item =
leveldb::mojom::BatchedOperation::New();
item->type = leveldb::mojom::BatchOperationType::PUT_KEY;
item->key = leveldb::StdStringToUint8Vector(kVersionKey);
item->value = leveldb::StdStringToUint8Vector(
base::NumberToString(kCurrentLocalStorageSchemaVersion));
operations.push_back(std::move(item));
const std::string version =
base::NumberToString(kCurrentLocalStorageSchemaVersion);
extra_entries_to_add->emplace_back(
storage::DomStorageDatabase::Key(kVersionKey.begin(),
kVersionKey.end()),
storage::DomStorageDatabase::Value(version.begin(), version.end()));
context_->database_initialized_ = true;
}
leveldb::mojom::BatchedOperationPtr item =
leveldb::mojom::BatchedOperation::New();
item->type = leveldb::mojom::BatchOperationType::PUT_KEY;
item->key = CreateMetaDataKey(origin_);
storage::DomStorageDatabase::Key metadata_key = CreateMetaDataKey(origin_);
if (storage_area()->empty()) {
item->type = leveldb::mojom::BatchOperationType::DELETE_KEY;
extra_keys_to_delete->push_back(std::move(metadata_key));
} else {
item->type = leveldb::mojom::BatchOperationType::PUT_KEY;
LocalStorageOriginMetaData data;
data.set_last_modified(base::Time::Now().ToInternalValue());
data.set_size_bytes(storage_area()->storage_used());
item->value = leveldb::StdStringToUint8Vector(data.SerializeAsString());
std::string serialized_data = data.SerializeAsString();
extra_entries_to_add->emplace_back(
std::move(metadata_key),
storage::DomStorageDatabase::Value(serialized_data.begin(),
serialized_data.end()));
}
operations.push_back(std::move(item));
return operations;
}
void DidCommit(leveldb::mojom::DatabaseError error) override {
void DidCommit(leveldb::Status status) override {
UMA_HISTOGRAM_ENUMERATION("LocalStorageContext.CommitResult",
leveldb::GetLevelDBStatusUMAValue(error),
leveldb_env::GetLevelDBStatusUMAValue(status),
leveldb_env::LEVELDB_STATUS_MAX);
// Delete any old database that might still exist if we successfully wrote
// data to LevelDB, and our LevelDB is actually disk backed.
if (error == leveldb::mojom::DatabaseError::OK && !deleted_old_data_ &&
!context_->directory_.empty() && context_->task_runner_ &&
!context_->old_localstorage_path_.empty()) {
if (status.ok() && !deleted_old_data_ && !context_->directory_.empty() &&
context_->task_runner_ && !context_->old_localstorage_path_.empty()) {
deleted_old_data_ = true;
context_->task_runner_->PostShutdownBlockingTask(
FROM_HERE, DOMStorageTaskRunner::PRIMARY_SEQUENCE,
......@@ -273,7 +283,7 @@ class LocalStorageContextMojo::StorageAreaHolder final
sql_db_path()));
}
context_->OnCommitResult(error);
context_->OnCommitResult(leveldb::LeveldbStatusToError(status));
}
void MigrateData(StorageAreaImpl::ValueMapCallback callback) override {
......@@ -455,11 +465,11 @@ void LocalStorageContextMojo::DeleteStorage(const url::Origin& origin,
"\n", base::BindOnce(&SuccessResponse, std::move(callback)));
found->second->storage_area()->ScheduleImmediateCommit();
} else if (database_) {
std::vector<leveldb::mojom::BatchedOperationPtr> operations;
AddDeleteOriginOperations(&operations, origin);
database_->Write(
std::move(operations),
base::BindOnce(&DatabaseErrorResponse, std::move(callback)));
DeleteOrigins(
database_.get(), {std::move(origin)},
base::BindOnce([](base::OnceClosure callback,
leveldb::Status) { std::move(callback).Run(); },
std::move(callback)));
} else {
std::move(callback).Run();
}
......@@ -510,7 +520,7 @@ void LocalStorageContextMojo::ShutdownAndDelete() {
// Nothing to do if no connection to the database was ever finished.
if (connection_state_ != CONNECTION_FINISHED) {
connection_state_ = CONNECTION_SHUTDOWN;
OnShutdownComplete(leveldb::mojom::DatabaseError::OK);
OnShutdownComplete(leveldb::Status::OK());
return;
}
......@@ -531,7 +541,7 @@ void LocalStorageContextMojo::ShutdownAndDelete() {
// Respect the content policy settings about what to
// keep and what to discard.
if (force_keep_session_state_) {
OnShutdownComplete(leveldb::mojom::DatabaseError::OK);
OnShutdownComplete(leveldb::Status::OK());
return; // Keep everything.
}
......@@ -544,7 +554,7 @@ void LocalStorageContextMojo::ShutdownAndDelete() {
base::BindOnce(&LocalStorageContextMojo::OnGotStorageUsageForShutdown,
base::Unretained(this)));
} else {
OnShutdownComplete(leveldb::mojom::DatabaseError::OK);
OnShutdownComplete(leveldb::Status::OK());
}
}
......@@ -759,7 +769,7 @@ void LocalStorageContextMojo::OnDatabaseOpened(
// Verify DB schema version.
if (database_) {
database_->Get(
leveldb::StdStringToUint8Vector(kVersionKey),
std::vector<uint8_t>(kVersionKey.begin(), kVersionKey.end()),
base::BindOnce(&LocalStorageContextMojo::OnGotDatabaseVersion,
weak_ptr_factory_.GetWeakPtr()));
return;
......@@ -967,28 +977,25 @@ void LocalStorageContextMojo::OnGotMetaData(
void LocalStorageContextMojo::OnGotStorageUsageForShutdown(
std::vector<StorageUsageInfo> usage) {
std::vector<leveldb::mojom::BatchedOperationPtr> operations;
std::vector<url::Origin> origins_to_delete;
for (const auto& info : usage) {
if (special_storage_policy_->IsStorageProtected(info.origin.GetURL()))
continue;
if (!special_storage_policy_->IsStorageSessionOnly(info.origin.GetURL()))
continue;
AddDeleteOriginOperations(&operations, info.origin);
origins_to_delete.push_back(info.origin);
}
if (!operations.empty()) {
database_->Write(
std::move(operations),
if (!origins_to_delete.empty()) {
DeleteOrigins(database_.get(), std::move(origins_to_delete),
base::BindOnce(&LocalStorageContextMojo::OnShutdownComplete,
base::Unretained(this)));
} else {
OnShutdownComplete(leveldb::mojom::DatabaseError::OK);
OnShutdownComplete(leveldb::Status::OK());
}
}
void LocalStorageContextMojo::OnShutdownComplete(
leveldb::mojom::DatabaseError error) {
void LocalStorageContextMojo::OnShutdownComplete(leveldb::Status status) {
delete this;
}
......
......@@ -156,7 +156,7 @@ class CONTENT_EXPORT LocalStorageContextMojo
std::vector<leveldb::mojom::KeyValuePtr> data);
void OnGotStorageUsageForShutdown(std::vector<StorageUsageInfo> usage);
void OnShutdownComplete(leveldb::mojom::DatabaseError error);
void OnShutdownComplete(leveldb::Status status);
void GetStatistics(size_t* total_cache_size, size_t* unused_area_count);
void OnCommitResult(leveldb::mojom::DatabaseError error);
......
......@@ -7,6 +7,7 @@
#include "base/system/sys_info.h"
#include "base/time/time.h"
#include "build/build_config.h"
#include "components/services/leveldb/public/cpp/util.h"
#include "content/browser/dom_storage/dom_storage_types.h"
namespace content {
......@@ -38,13 +39,8 @@ scoped_refptr<SessionStorageDataMap> SessionStorageDataMap::CreateClone(
listener, std::move(map_data), std::move(clone_from)));
}
std::vector<leveldb::mojom::BatchedOperationPtr>
SessionStorageDataMap::PrepareToCommit() {
return std::vector<leveldb::mojom::BatchedOperationPtr>();
}
void SessionStorageDataMap::DidCommit(leveldb::mojom::DatabaseError error) {
listener_->OnCommitResult(error);
void SessionStorageDataMap::DidCommit(leveldb::Status status) {
listener_->OnCommitResult(leveldb::LeveldbStatusToError(status));
}
SessionStorageDataMap::SessionStorageDataMap(
......
......@@ -77,9 +77,7 @@ class CONTENT_EXPORT SessionStorageDataMap final
// Note: this is irrelevant, as the parent area is handling binding.
void OnNoBindings() override {}
std::vector<leveldb::mojom::BatchedOperationPtr> PrepareToCommit() override;
void DidCommit(leveldb::mojom::DatabaseError error) override;
void DidCommit(leveldb::Status status) override;
private:
friend class base::RefCounted<SessionStorageDataMap>;
......
......@@ -6,6 +6,7 @@
#include "base/bind.h"
#include "base/bind_helpers.h"
#include "base/containers/span.h"
#include "base/metrics/histogram_macros.h"
#include "base/threading/thread_task_runner_handle.h"
#include "base/trace_event/memory_dump_manager.h"
......@@ -14,16 +15,21 @@
#include "components/services/leveldb/public/cpp/util.h"
#include "content/public/browser/browser_thread.h"
#include "mojo/public/cpp/bindings/associated_remote.h"
#include "third_party/leveldatabase/env_chromium.h"
namespace content {
namespace {
using leveldb::mojom::BatchedOperation;
using leveldb::mojom::BatchedOperationPtr;
using leveldb::mojom::DatabaseError;
} // namespace
StorageAreaImpl::Delegate::~Delegate() {}
void StorageAreaImpl::Delegate::PrepareToCommit(
std::vector<storage::DomStorageDatabase::KeyValuePair>*
extra_entries_to_add,
std::vector<storage::DomStorageDatabase::Key>* extra_keys_to_delete) {}
void StorageAreaImpl::Delegate::MigrateData(
base::OnceCallback<void(std::unique_ptr<ValueMap>)> callback) {
std::move(callback).Run(nullptr);
......@@ -756,56 +762,60 @@ void StorageAreaImpl::CommitChanges() {
commit_rate_limiter_.add_samples(1);
// Commit all our changes in a single batch.
std::vector<BatchedOperationPtr> operations = delegate_->PrepareToCommit();
bool has_changes = !operations.empty() ||
struct Commit {
storage::DomStorageDatabase::Key prefix;
bool clear_all_first;
std::vector<storage::DomStorageDatabase::KeyValuePair> entries_to_add;
std::vector<storage::DomStorageDatabase::Key> keys_to_delete;
base::Optional<storage::DomStorageDatabase::Key> copy_to_prefix;
};
Commit commit;
commit.prefix = prefix_;
commit.clear_all_first = commit_batch_->clear_all_first;
delegate_->PrepareToCommit(&commit.entries_to_add, &commit.keys_to_delete);
const bool has_changes = !commit.entries_to_add.empty() ||
!commit.keys_to_delete.empty() ||
!commit_batch_->changed_values.empty() ||
!commit_batch_->changed_keys.empty();
if (commit_batch_->clear_all_first) {
BatchedOperationPtr item = BatchedOperation::New();
item->type = leveldb::mojom::BatchOperationType::DELETE_PREFIXED_KEY;
item->key = prefix_;
operations.push_back(std::move(item));
}
size_t data_size = 0;
if (map_state_ == MapState::LOADED_KEYS_AND_VALUES) {
DCHECK(commit_batch_->changed_values.empty())
<< "Map state and commit state out of sync.";
for (const auto& key : commit_batch_->changed_keys) {
data_size += key.size();
BatchedOperationPtr item = BatchedOperation::New();
item->key.reserve(prefix_.size() + key.size());
item->key.insert(item->key.end(), prefix_.begin(), prefix_.end());
item->key.insert(item->key.end(), key.begin(), key.end());
auto kv_it = keys_values_map_.find(key);
if (kv_it != keys_values_map_.end()) {
item->type = leveldb::mojom::BatchOperationType::PUT_KEY;
data_size += kv_it->second.size();
item->value = kv_it->second;
storage::DomStorageDatabase::Key prefixed_key;
prefixed_key.reserve(prefix_.size() + key.size());
prefixed_key.insert(prefixed_key.end(), prefix_.begin(), prefix_.end());
prefixed_key.insert(prefixed_key.end(), key.begin(), key.end());
auto it = keys_values_map_.find(key);
if (it != keys_values_map_.end()) {
data_size += it->second.size();
commit.entries_to_add.emplace_back(std::move(prefixed_key), it->second);
} else {
item->type = leveldb::mojom::BatchOperationType::DELETE_KEY;
commit.keys_to_delete.push_back(std::move(prefixed_key));
}
operations.push_back(std::move(item));
}
} else {
DCHECK(commit_batch_->changed_keys.empty())
<< "Map state and commit state out of sync.";
DCHECK_EQ(map_state_, MapState::LOADED_KEYS_ONLY);
for (auto& it : commit_batch_->changed_values) {
const auto& key = it.first;
for (auto& entry : commit_batch_->changed_values) {
const auto& key = entry.first;
data_size += key.size();
BatchedOperationPtr item = BatchedOperation::New();
item->key.reserve(prefix_.size() + key.size());
item->key.insert(item->key.end(), prefix_.begin(), prefix_.end());
item->key.insert(item->key.end(), key.begin(), key.end());
auto kv_it = keys_only_map_.find(key);
if (kv_it != keys_only_map_.end()) {
item->type = leveldb::mojom::BatchOperationType::PUT_KEY;
data_size += it.second.size();
item->value = std::move(it.second);
storage::DomStorageDatabase::Key prefixed_key;
prefixed_key.reserve(prefix_.size() + key.size());
prefixed_key.insert(prefixed_key.end(), prefix_.begin(), prefix_.end());
prefixed_key.insert(prefixed_key.end(), key.begin(), key.end());
auto it = keys_only_map_.find(key);
if (it != keys_only_map_.end()) {
data_size += entry.second.size();
commit.entries_to_add.emplace_back(std::move(prefixed_key),
std::move(entry.second));
} else {
item->type = leveldb::mojom::BatchOperationType::DELETE_KEY;
commit.keys_to_delete.push_back(std::move(prefixed_key));
}
operations.push_back(std::move(item));
}
}
// Schedule the copy, and ignore if |clear_all_first| is specified and there
......@@ -813,11 +823,7 @@ void StorageAreaImpl::CommitChanges() {
if (commit_batch_->copy_to_prefix) {
DCHECK(!has_changes);
DCHECK(!commit_batch_->clear_all_first);
BatchedOperationPtr item = BatchedOperation::New();
item->type = leveldb::mojom::BatchOperationType::COPY_PREFIXED_KEY;
item->key = prefix_;
item->value = std::move(commit_batch_->copy_to_prefix.value());
operations.push_back(std::move(item));
commit.copy_to_prefix = std::move(commit_batch_->copy_to_prefix);
}
commit_batch_.reset();
......@@ -825,26 +831,41 @@ void StorageAreaImpl::CommitChanges() {
++commit_batches_in_flight_;
// TODO(michaeln): Currently there is no guarantee LevelDBDatabaseImpl::Write
// will run during a clean shutdown. We need that to avoid dataloss.
database_->Write(std::move(operations),
database_->RunDatabaseTask(
base::BindOnce(
[](Commit commit, const storage::DomStorageDatabase& db) {
leveldb::WriteBatch batch;
if (commit.clear_all_first)
db.DeletePrefixed(commit.prefix, &batch);
for (const auto& entry : commit.entries_to_add) {
batch.Put(leveldb_env::MakeSlice(entry.key),
leveldb_env::MakeSlice(entry.value));
}
for (const auto& key : commit.keys_to_delete)
batch.Delete(leveldb_env::MakeSlice(key));
if (commit.copy_to_prefix) {
db.CopyPrefixed(commit.prefix, commit.copy_to_prefix.value(),
&batch);
}
return db.Commit(&batch);
},
std::move(commit)),
base::BindOnce(&StorageAreaImpl::OnCommitComplete,
weak_ptr_factory_.GetWeakPtr()));
}
void StorageAreaImpl::OnCommitComplete(DatabaseError error) {
void StorageAreaImpl::OnCommitComplete(leveldb::Status status) {
has_committed_data_ = true;
--commit_batches_in_flight_;
StartCommitTimer();
if (error != DatabaseError::OK) {
if (!status.ok())
SetCacheMode(CacheMode::KEYS_AND_VALUES);
}
// Call before |DidCommit| as delegate can destroy this object.
UnloadMapIfPossible();
delegate_->DidCommit(error);
delegate_->DidCommit(status);
}
void StorageAreaImpl::UnloadMapIfPossible() {
......
......@@ -16,6 +16,7 @@
#include "base/optional.h"
#include "base/time/time.h"
#include "components/services/leveldb/public/mojom/leveldb.mojom.h"
#include "components/services/storage/dom_storage/dom_storage_database.h"
#include "content/common/content_export.h"
#include "mojo/public/cpp/bindings/pending_associated_remote.h"
#include "mojo/public/cpp/bindings/pending_receiver.h"
......@@ -58,9 +59,11 @@ class CONTENT_EXPORT StorageAreaImpl : public blink::mojom::StorageArea {
public:
virtual ~Delegate();
virtual void OnNoBindings() = 0;
virtual std::vector<leveldb::mojom::BatchedOperationPtr>
PrepareToCommit() = 0;
virtual void DidCommit(leveldb::mojom::DatabaseError error) = 0;
virtual void PrepareToCommit(
std::vector<storage::DomStorageDatabase::KeyValuePair>*
extra_entries_to_add,
std::vector<storage::DomStorageDatabase::Key>* extra_keys_to_delete);
virtual void DidCommit(leveldb::Status error) = 0;
// Called during loading if no data was found. Needs to call |callback|.
virtual void MigrateData(ValueMapCallback callback);
// Called during loading to give delegate a chance to modify the data as
......@@ -296,7 +299,7 @@ class CONTENT_EXPORT StorageAreaImpl : public blink::mojom::StorageArea {
base::TimeDelta ComputeCommitDelay() const;
void CommitChanges();
void OnCommitComplete(leveldb::mojom::DatabaseError error);
void OnCommitComplete(leveldb::Status status);
void UnloadMapIfPossible();
......
......@@ -53,11 +53,8 @@ class MockDelegate : public StorageAreaImpl::Delegate {
~MockDelegate() override {}
void OnNoBindings() override {}
std::vector<leveldb::mojom::BatchedOperationPtr> PrepareToCommit() override {
return std::vector<leveldb::mojom::BatchedOperationPtr>();
}
void DidCommit(DatabaseError error) override {
if (error != DatabaseError::OK)
void DidCommit(leveldb::Status status) override {
if (!status.ok())
LOG(ERROR) << "error committing!";
if (committed_)
std::move(committed_).Run();
......
......@@ -1699,6 +1699,11 @@ leveldb::Slice MakeSlice(const base::StringPiece& s) {
return leveldb::Slice(s.begin(), s.size());
}
leveldb::Slice MakeSlice(base::span<const uint8_t> s) {
return MakeSlice(
base::StringPiece(reinterpret_cast<const char*>(s.data()), s.size()));
}
} // namespace leveldb_env
namespace leveldb {
......
......@@ -372,6 +372,7 @@ LEVELDB_EXPORT leveldb::Status RewriteDB(const leveldb_env::Options& options,
LEVELDB_EXPORT base::StringPiece MakeStringPiece(const leveldb::Slice& s);
LEVELDB_EXPORT leveldb::Slice MakeSlice(const base::StringPiece& s);
LEVELDB_EXPORT leveldb::Slice MakeSlice(base::span<const uint8_t> s);
} // namespace leveldb_env
......
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