WIP - JSON mpsites serialization.
This commit is contained in:
parent
cb74b1f3fc
commit
1031414ba2
@ -174,6 +174,11 @@ public enum MPResultType {
|
||||
return typeFeatures;
|
||||
}
|
||||
|
||||
public boolean supportsTypeFeature(final MPSiteFeature feature) {
|
||||
|
||||
return typeFeatures.contains( feature );
|
||||
}
|
||||
|
||||
public int getType() {
|
||||
int mask = typeIndex | typeClass.getMask();
|
||||
for (final MPSiteFeature typeFeature : typeFeatures)
|
||||
|
@ -9,6 +9,7 @@ dependencies {
|
||||
compile project( ':masterpassword-algorithm' )
|
||||
|
||||
compile group: 'joda-time', name: 'joda-time', version: '2.4'
|
||||
compile group: 'com.google.code.gson', name: 'gson', version: '2.8.2'
|
||||
compileOnly group: 'com.google.auto.value', name: 'auto-value', version: '1.2'
|
||||
apt group: 'com.google.auto.value', name: 'auto-value', version: '1.2'
|
||||
|
||||
|
@ -0,0 +1,53 @@
|
||||
//==============================================================================
|
||||
// This file is part of Master Password.
|
||||
// Copyright (c) 2011-2017, Maarten Billemont.
|
||||
//
|
||||
// Master Password is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Master Password is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You can find a copy of the GNU General Public License in the
|
||||
// LICENSE file. Alternatively, see <http://www.gnu.org/licenses/>.
|
||||
//==============================================================================
|
||||
|
||||
package com.lyndir.masterpassword.model;
|
||||
|
||||
import com.google.gson.*;
|
||||
import java.lang.reflect.Type;
|
||||
|
||||
|
||||
/**
|
||||
* @author lhunath, 2018-04-27
|
||||
*/
|
||||
public class EnumOrdinalAdapter implements JsonSerializer<Enum<?>>, JsonDeserializer<Enum<?>> {
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
public Enum<?> deserialize(final JsonElement json, final Type typeOfT, final JsonDeserializationContext context)
|
||||
throws JsonParseException {
|
||||
Enum<?>[] enumConstants = ((Class<Enum<?>>) typeOfT).getEnumConstants();
|
||||
if (enumConstants == null)
|
||||
throw new JsonParseException( "Not an enum: " + typeOfT );
|
||||
|
||||
try {
|
||||
int ordinal = json.getAsInt();
|
||||
if ((ordinal < 0) || (ordinal >= enumConstants.length))
|
||||
throw new JsonParseException( "No ordinal " + ordinal + " in enum: " + typeOfT );
|
||||
|
||||
return enumConstants[ordinal];
|
||||
} catch (final ClassCastException | IllegalStateException e) {
|
||||
throw new JsonParseException( "Not an ordinal value: " + json, e );
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public JsonElement serialize(final Enum<?> src, final Type typeOfSrc, final JsonSerializationContext context) {
|
||||
return new JsonPrimitive( src.ordinal() );
|
||||
}
|
||||
}
|
@ -39,7 +39,6 @@ public class MPFileSite extends MPSite {
|
||||
|
||||
@Nullable
|
||||
private String loginContent;
|
||||
@Nullable
|
||||
private MPResultType loginType;
|
||||
|
||||
@Nullable
|
||||
@ -48,35 +47,27 @@ public class MPFileSite extends MPSite {
|
||||
private Instant lastUsed;
|
||||
|
||||
public MPFileSite(final MPFileUser user, final String siteName) {
|
||||
this( user, siteName,
|
||||
user.getAlgorithm().mpw_default_counter(),
|
||||
user.getAlgorithm().mpw_default_type(),
|
||||
user.getAlgorithm() );
|
||||
this( user, siteName, null, null, user.getAlgorithm() );
|
||||
}
|
||||
|
||||
public MPFileSite(final MPFileUser user, final String siteName, final UnsignedInteger siteCounter, final MPResultType resultType,
|
||||
final MPAlgorithm algorithm) {
|
||||
this.user = user;
|
||||
this.siteName = siteName;
|
||||
this.siteCounter = siteCounter;
|
||||
this.resultType = resultType;
|
||||
this.algorithm = algorithm;
|
||||
this.lastUsed = new Instant();
|
||||
public MPFileSite(final MPFileUser user, final String siteName, @Nullable final UnsignedInteger siteCounter,
|
||||
@Nullable final MPResultType resultType, final MPAlgorithm algorithm) {
|
||||
this( user, siteName, null, siteCounter, resultType, algorithm,
|
||||
null, null, null, 0, new Instant() );
|
||||
}
|
||||
|
||||
protected MPFileSite(final MPFileUser user, final String siteName, @Nullable final String siteContent,
|
||||
final UnsignedInteger siteCounter,
|
||||
final MPResultType resultType, final MPAlgorithm algorithm,
|
||||
@Nullable final UnsignedInteger siteCounter, @Nullable final MPResultType resultType, final MPAlgorithm algorithm,
|
||||
@Nullable final String loginContent, @Nullable final MPResultType loginType,
|
||||
@Nullable final String url, final int uses, final Instant lastUsed) {
|
||||
this.user = user;
|
||||
this.siteName = siteName;
|
||||
this.siteContent = siteContent;
|
||||
this.siteCounter = siteCounter;
|
||||
this.resultType = resultType;
|
||||
this.siteCounter = (siteCounter == null)? user.getAlgorithm().mpw_default_counter(): siteCounter;
|
||||
this.resultType = (resultType == null)? user.getAlgorithm().mpw_default_type(): resultType;
|
||||
this.algorithm = algorithm;
|
||||
this.loginContent = loginContent;
|
||||
this.loginType = loginType;
|
||||
this.loginType = (loginType == null)? MPResultType.GeneratedName: loginType;
|
||||
this.url = url;
|
||||
this.uses = uses;
|
||||
this.lastUsed = lastUsed;
|
||||
@ -163,7 +154,6 @@ public class MPFileSite extends MPSite {
|
||||
this.algorithm = algorithm;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public MPResultType getLoginType() {
|
||||
return loginType;
|
||||
}
|
||||
|
@ -108,7 +108,7 @@ public class MPFileUserManager extends MPUserManager {
|
||||
super.deleteUser( user );
|
||||
|
||||
// Remove deleted users.
|
||||
File userFile = getUserFile( user );
|
||||
File userFile = getUserFile( user, user.getFormat() );
|
||||
if (userFile.exists() && !userFile.delete())
|
||||
logger.err( "Couldn't delete file: %s", userFile );
|
||||
}
|
||||
@ -119,13 +119,14 @@ public class MPFileUserManager extends MPUserManager {
|
||||
public void save(final MPFileUser user, final MPMasterKey masterKey)
|
||||
throws MPInvalidatedException {
|
||||
try {
|
||||
final MPMarshalFormat format = user.getFormat();
|
||||
new CharSink() {
|
||||
@Override
|
||||
public Writer openStream()
|
||||
throws IOException {
|
||||
return new OutputStreamWriter( new FileOutputStream( getUserFile( user ) ), Charsets.UTF_8 );
|
||||
return new OutputStreamWriter( new FileOutputStream( getUserFile( user, format ) ), Charsets.UTF_8 );
|
||||
}
|
||||
}.write( user.getFormat().marshaller().marshall( user, masterKey, MPMarshaller.ContentMode.PROTECTED ) );
|
||||
}.write( format.marshaller().marshall( user, masterKey, MPMarshaller.ContentMode.PROTECTED ) );
|
||||
}
|
||||
catch (final MPMarshalException | IOException e) {
|
||||
logger.err( e, "Unable to save sites for user: %s", user );
|
||||
@ -133,8 +134,8 @@ public class MPFileUserManager extends MPUserManager {
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
private File getUserFile(final MPFileUser user) {
|
||||
return new File( path, user.getFullName() + ".mpsites" );
|
||||
private File getUserFile(final MPFileUser user, final MPMarshalFormat format) {
|
||||
return new File( path, user.getFullName() + format.fileSuffix() );
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -0,0 +1,193 @@
|
||||
//==============================================================================
|
||||
// This file is part of Master Password.
|
||||
// Copyright (c) 2011-2017, Maarten Billemont.
|
||||
//
|
||||
// Master Password is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Master Password is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You can find a copy of the GNU General Public License in the
|
||||
// LICENSE file. Alternatively, see <http://www.gnu.org/licenses/>.
|
||||
//==============================================================================
|
||||
|
||||
package com.lyndir.masterpassword.model;
|
||||
|
||||
import com.google.common.primitives.UnsignedInteger;
|
||||
import com.lyndir.lhunath.opal.system.CodeUtils;
|
||||
import com.lyndir.masterpassword.*;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import javax.annotation.Nullable;
|
||||
import org.joda.time.Instant;
|
||||
import org.joda.time.format.DateTimeFormatter;
|
||||
import org.joda.time.format.ISODateTimeFormat;
|
||||
|
||||
|
||||
/**
|
||||
* @author lhunath, 2018-04-27
|
||||
*/
|
||||
public class MPJSONFile {
|
||||
|
||||
private static final DateTimeFormatter dateFormatter = ISODateTimeFormat.dateTimeNoMillis();
|
||||
|
||||
Export export;
|
||||
User user;
|
||||
|
||||
public MPJSONFile(final MPFileUser user, final MPMasterKey masterKey, final MPMarshaller.ContentMode contentMode)
|
||||
throws MPInvalidatedException {
|
||||
// if (!user.fullName || !strlen( user.fullName )) {
|
||||
// *error = (MPMarshalError){ MPMarshalErrorMissing, "Missing full name." };
|
||||
// return false;
|
||||
// }
|
||||
// if (!user.masterPassword || !strlen( user.masterPassword )) {
|
||||
// *error = (MPMarshalError){ MPMarshalErrorMasterPassword, "Missing master password." };
|
||||
// return false;
|
||||
// }
|
||||
// if (!mpw_update_masterKey( &masterKey, &masterKeyAlgorithm, user.algorithm, user.fullName, user.masterPassword )) {
|
||||
// *error = (MPMarshalError){ MPMarshalErrorInternal, "Couldn't derive master key." };
|
||||
// return false;
|
||||
// }
|
||||
|
||||
// Section: "export"
|
||||
Export fileExport = this.export = new Export();
|
||||
fileExport.format = 1;
|
||||
fileExport.redacted = contentMode.isRedacted();
|
||||
fileExport.date = dateFormatter.print( new Instant() );
|
||||
|
||||
// Section: "user"
|
||||
User fileUser = this.user = new User();
|
||||
fileUser.avatar = user.getAvatar();
|
||||
fileUser.fullName = user.getFullName();
|
||||
|
||||
fileUser.lastUsed = dateFormatter.print( user.getLastUsed() );
|
||||
fileUser.keyId = CodeUtils.encodeHex( masterKey.getKeyID( user.getAlgorithm() ) );
|
||||
|
||||
fileUser.algorithm = user.getAlgorithm().version();
|
||||
fileUser.defaultType = user.getDefaultType();
|
||||
|
||||
// Section "sites"
|
||||
fileUser.sites = new LinkedHashMap<>();
|
||||
for (final MPFileSite site : user.getSites()) {
|
||||
Site fileSite;
|
||||
String content = null, loginContent = null;
|
||||
if (!contentMode.isRedacted()) {
|
||||
// Clear Text
|
||||
content = masterKey.siteResult( site.getSiteName(), site.getSiteCounter(),
|
||||
MPKeyPurpose.Authentication, null, site.getResultType(), site.getSiteContent(),
|
||||
site.getAlgorithm() );
|
||||
loginContent = masterKey.siteResult( site.getSiteName(), site.getAlgorithm().mpw_default_counter(),
|
||||
MPKeyPurpose.Identification, null, site.getLoginType(), site.getLoginContent(),
|
||||
site.getAlgorithm() );
|
||||
} else {
|
||||
// Redacted
|
||||
if (site.getResultType().supportsTypeFeature( MPSiteFeature.ExportContent ))
|
||||
content = site.getSiteContent();
|
||||
if (site.getLoginType().supportsTypeFeature( MPSiteFeature.ExportContent ))
|
||||
loginContent = site.getLoginContent();
|
||||
}
|
||||
|
||||
fileUser.sites.put( site.getSiteName(), fileSite = new Site() );
|
||||
fileSite.type = site.getResultType();
|
||||
fileSite.counter = site.getSiteCounter();
|
||||
fileSite.algorithm = site.getAlgorithm().version();
|
||||
fileSite.password = content;
|
||||
fileSite.login_name = loginContent;
|
||||
fileSite.loginType = site.getLoginType();
|
||||
|
||||
fileSite.uses = site.getUses();
|
||||
fileSite.lastUsed = dateFormatter.print( site.getLastUsed() );
|
||||
|
||||
fileSite.questions = new LinkedHashMap<>();
|
||||
// for (size_t q = 0; q < site.questions_count; ++q) {
|
||||
// MPMarshalledQuestion *question = &site.questions[q];
|
||||
// if (!question.keyword)
|
||||
// continue;
|
||||
//
|
||||
// json_object *json_site_question = json_object_new_object();
|
||||
// json_object_object_add( json_site_questions, question.keyword, json_site_question );
|
||||
// json_object_object_add( json_site_question, "type = question.type;
|
||||
//
|
||||
// if (!user.redacted) {
|
||||
// // Clear Text
|
||||
// const char *answerContent = mpw_siteResult( masterKey, site.name, MPCounterValueInitial,
|
||||
// MPKeyPurposeRecovery, question.keyword, question.type, question.content, site.algorithm );
|
||||
// json_object_object_add( json_site_question, "answer = answerContent;
|
||||
// }
|
||||
// else {
|
||||
// // Redacted
|
||||
// if (site.type & MPSiteFeatureExportContent && question.content && strlen( question.content ))
|
||||
// json_object_object_add( json_site_question, "answer = question.content;
|
||||
// }
|
||||
// }
|
||||
|
||||
// json_object *json_site_mpw = json_object_new_object();
|
||||
// fileSite._ext_mpw = json_site_mpw;
|
||||
// if (site.url)
|
||||
// json_object_object_add( json_site_mpw, "url", site.url );
|
||||
}
|
||||
}
|
||||
|
||||
public MPFileUser toUser() {
|
||||
return new MPFileUser( user.fullName, CodeUtils.decodeHex( user.keyId ), user.algorithm.getAlgorithm(), user.avatar, user.defaultType, dateFormatter.parseDateTime( user.lastUsed ), MPMarshalFormat.JSON );
|
||||
}
|
||||
|
||||
public static class Export {
|
||||
|
||||
int format;
|
||||
boolean redacted;
|
||||
String date;
|
||||
}
|
||||
|
||||
|
||||
public static class User {
|
||||
|
||||
String fullName;
|
||||
|
||||
MPMasterKey.Version algorithm;
|
||||
boolean redacted;
|
||||
|
||||
int avatar;
|
||||
MPResultType defaultType;
|
||||
String lastUsed;
|
||||
String keyId;
|
||||
|
||||
Map<String, Site> sites;
|
||||
}
|
||||
|
||||
|
||||
public static class Site {
|
||||
|
||||
@Nullable
|
||||
String password;
|
||||
@Nullable
|
||||
String login_name;
|
||||
String name;
|
||||
String content;
|
||||
MPResultType type;
|
||||
UnsignedInteger counter;
|
||||
MPMasterKey.Version algorithm;
|
||||
|
||||
String loginContent;
|
||||
MPResultType loginType;
|
||||
|
||||
String url;
|
||||
int uses;
|
||||
String lastUsed;
|
||||
|
||||
Map<String, Question> questions;
|
||||
}
|
||||
|
||||
|
||||
public static class Question {
|
||||
|
||||
String keyword;
|
||||
String content;
|
||||
MPResultType type;
|
||||
}
|
||||
}
|
@ -18,8 +18,8 @@
|
||||
|
||||
package com.lyndir.masterpassword.model;
|
||||
|
||||
import com.lyndir.masterpassword.MPInvalidatedException;
|
||||
import com.lyndir.masterpassword.MPMasterKey;
|
||||
import com.google.gson.*;
|
||||
import com.lyndir.masterpassword.*;
|
||||
import javax.annotation.Nonnull;
|
||||
|
||||
|
||||
@ -28,10 +28,17 @@ import javax.annotation.Nonnull;
|
||||
*/
|
||||
public class MPJSONMarshaller implements MPMarshaller {
|
||||
|
||||
private final Gson gson = new GsonBuilder()
|
||||
.registerTypeAdapter( MPMasterKey.Version.class, new EnumOrdinalAdapter() )
|
||||
.registerTypeAdapter( MPResultType.class, new MPResultTypeAdapter() )
|
||||
.setFieldNamingStrategy( FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES )
|
||||
.setPrettyPrinting().create();
|
||||
|
||||
@Nonnull
|
||||
@Override
|
||||
public String marshall(final MPFileUser user, final MPMasterKey masterKey, final ContentMode contentMode)
|
||||
throws MPInvalidatedException, MPMarshalException {
|
||||
throw new MPMarshalException( "Not yet implemented" );
|
||||
|
||||
return gson.toJson( new MPJSONFile( user, masterKey, contentMode ) );
|
||||
}
|
||||
}
|
||||
|
@ -18,8 +18,11 @@
|
||||
|
||||
package com.lyndir.masterpassword.model;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import com.google.gson.*;
|
||||
import com.lyndir.masterpassword.MPMasterKey;
|
||||
import com.lyndir.masterpassword.MPResultType;
|
||||
import java.io.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import javax.annotation.Nonnull;
|
||||
|
||||
|
||||
@ -28,17 +31,27 @@ import javax.annotation.Nonnull;
|
||||
*/
|
||||
public class MPJSONUnmarshaller implements MPUnmarshaller {
|
||||
|
||||
private final Gson gson = new GsonBuilder()
|
||||
.registerTypeAdapter( MPMasterKey.Version.class, new EnumOrdinalAdapter() )
|
||||
.registerTypeAdapter( MPResultType.class, new MPResultTypeAdapter() )
|
||||
.setFieldNamingStrategy( FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES )
|
||||
.setPrettyPrinting().create();
|
||||
|
||||
@Nonnull
|
||||
@Override
|
||||
public MPFileUser unmarshall(@Nonnull final File file)
|
||||
throws IOException, MPMarshalException {
|
||||
throw new MPMarshalException( "Not yet implemented" );
|
||||
|
||||
try (Reader reader = new InputStreamReader( new FileInputStream( file ), StandardCharsets.UTF_8 )) {
|
||||
return gson.fromJson( reader, MPJSONFile.class ).toUser();
|
||||
}
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
@Override
|
||||
public MPFileUser unmarshall(@Nonnull final String content)
|
||||
throws MPMarshalException {
|
||||
throw new MPMarshalException( "Not yet implemented" );
|
||||
|
||||
return gson.fromJson( content, MPJSONFile.class ).toUser();
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,46 @@
|
||||
//==============================================================================
|
||||
// This file is part of Master Password.
|
||||
// Copyright (c) 2011-2017, Maarten Billemont.
|
||||
//
|
||||
// Master Password is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Master Password is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You can find a copy of the GNU General Public License in the
|
||||
// LICENSE file. Alternatively, see <http://www.gnu.org/licenses/>.
|
||||
//==============================================================================
|
||||
|
||||
package com.lyndir.masterpassword.model;
|
||||
|
||||
import com.google.gson.*;
|
||||
import com.lyndir.masterpassword.MPResultType;
|
||||
import java.lang.reflect.Type;
|
||||
|
||||
|
||||
/**
|
||||
* @author lhunath, 2018-04-27
|
||||
*/
|
||||
public class MPResultTypeAdapter implements JsonSerializer<MPResultType>, JsonDeserializer<MPResultType> {
|
||||
|
||||
@Override
|
||||
public MPResultType deserialize(final JsonElement json, final Type typeOfT, final JsonDeserializationContext context)
|
||||
throws JsonParseException {
|
||||
try {
|
||||
return MPResultType.forType( json.getAsInt() );
|
||||
}
|
||||
catch (final ClassCastException | IllegalStateException e) {
|
||||
throw new JsonParseException( "Not an ordinal value: " + json, e );
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public JsonElement serialize(final MPResultType src, final Type typeOfSrc, final JsonSerializationContext context) {
|
||||
return new JsonPrimitive( src.getType() );
|
||||
}
|
||||
}
|
@ -6,6 +6,7 @@ description = 'Master Password Test Suite'
|
||||
|
||||
dependencies {
|
||||
compile project( ':masterpassword-algorithm' )
|
||||
compile project( ':masterpassword-model' )
|
||||
|
||||
testCompile group: 'org.testng', name: 'testng', version: '6.8.5'
|
||||
testCompile group: 'ch.qos.logback', name: 'logback-classic', version: '1.1.2'
|
||||
|
@ -0,0 +1,37 @@
|
||||
//==============================================================================
|
||||
// This file is part of Master Password.
|
||||
// Copyright (c) 2011-2017, Maarten Billemont.
|
||||
//
|
||||
// Master Password is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Master Password is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You can find a copy of the GNU General Public License in the
|
||||
// LICENSE file. Alternatively, see <http://www.gnu.org/licenses/>.
|
||||
//==============================================================================
|
||||
|
||||
package com.lyndir.masterpassword;
|
||||
|
||||
import com.lyndir.masterpassword.model.MPJSONUnmarshaller;
|
||||
import java.io.File;
|
||||
import org.testng.annotations.Test;
|
||||
|
||||
|
||||
/**
|
||||
* @author lhunath, 2018-04-27
|
||||
*/
|
||||
public class MPModelTest {
|
||||
|
||||
@Test
|
||||
public void testMasterKey()
|
||||
throws Exception {
|
||||
System.err.println( new MPJSONUnmarshaller().unmarshall(
|
||||
new File( "/Users/lhunath/.mpw.d/Maarten Billemont.mpsites.json" ) ) );
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user