Browse Source

working on confirmed subscription

drop_old_mail
Stephan Richter 3 years ago
parent
commit
2b59c7ab96
  1. 45
      doc/data structure.dia
  2. 7
      src/main/java/de/srsoftware/widerhall/Application.java
  3. 20
      src/main/java/de/srsoftware/widerhall/data/ListMember.java
  4. 146
      src/main/java/de/srsoftware/widerhall/data/MailingList.java
  5. 13
      src/main/java/de/srsoftware/widerhall/mail/Forwarder.java
  6. 54
      src/main/java/de/srsoftware/widerhall/mail/SmtpClient.java
  7. 23
      src/main/java/de/srsoftware/widerhall/web/Rest.java
  8. 7
      src/main/java/de/srsoftware/widerhall/web/Web.java
  9. 6
      static/templates/js.st

45
doc/data structure.dia

@ -1560,5 +1560,50 @@ @@ -1560,5 +1560,50 @@
</dia:composite>
</dia:attribute>
</dia:object>
<dia:object type="Flowchart - Box" version="0" id="O34">
<dia:attribute name="obj_pos">
<dia:point val="12,27"/>
</dia:attribute>
<dia:attribute name="obj_bb">
<dia:rectangle val="11.95,26.95;17.05,29.05"/>
</dia:attribute>
<dia:attribute name="elem_corner">
<dia:point val="12,27"/>
</dia:attribute>
<dia:attribute name="elem_width">
<dia:real val="5"/>
</dia:attribute>
<dia:attribute name="elem_height">
<dia:real val="2"/>
</dia:attribute>
<dia:attribute name="show_background">
<dia:boolean val="true"/>
</dia:attribute>
<dia:attribute name="padding">
<dia:real val="0.5"/>
</dia:attribute>
<dia:attribute name="text">
<dia:composite type="text">
<dia:attribute name="string">
<dia:string>#State#</dia:string>
</dia:attribute>
<dia:attribute name="font">
<dia:font family="sans" style="0" name="Helvetica"/>
</dia:attribute>
<dia:attribute name="height">
<dia:real val="0.80000000000000004"/>
</dia:attribute>
<dia:attribute name="pos">
<dia:point val="14.5,28.1941"/>
</dia:attribute>
<dia:attribute name="color">
<dia:color val="#000000ff"/>
</dia:attribute>
<dia:attribute name="alignment">
<dia:enum val="1"/>
</dia:attribute>
</dia:composite>
</dia:attribute>
</dia:object>
</dia:layer>
</dia:diagram>

7
src/main/java/de/srsoftware/widerhall/Application.java

@ -43,11 +43,4 @@ public class Application { @@ -43,11 +43,4 @@ public class Application {
server.start();
}
private static void startMailSystem(JSONObject json) {
MessageHandler forward = new Forwarder(json);
new ImapClient(json)
.addListener(forward)
.start();
}
}

20
src/main/java/de/srsoftware/widerhall/data/ListMember.java

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
package de.srsoftware.widerhall.data;
import de.srsoftware.widerhall.Util;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -19,23 +20,27 @@ public class ListMember { @@ -19,23 +20,27 @@ public class ListMember {
public static final String TABLE_NAME = "ListMembers";
public static final int STATE_OWNER = 1;
public static final int STATE_SUBSCRIBER = 2;
public static final int STATE_UNCONFIRMED = 4;
public static final int STATE_AWAITING_CONFIRMATION = 4;
private static final String LIST_EMAIL = "list_email";
private static final String USER_EMAIL = "user_email";
private static final String STATE = "state";
private static final String TOKEN = "token";
private final String listEmail;
private final String userEmail;
private final String listEmail,token,userEmail;
private final int state;
public ListMember(String listEmail, String userEmail, int state){
public ListMember(String listEmail, String userEmail, int state, String token){
this.listEmail = listEmail;
this.userEmail = userEmail;
this.state = state;
this.token = token;
}
public static ListMember create(MailingList list, User user, int state) throws SQLException {
return new ListMember(list.email(),user.email(),state).save();
String token = null;
if ((state & STATE_AWAITING_CONFIRMATION) > 0){
token = Util.sha256(String.join("/",list.email(),user.email(),user.salt()));
}
return new ListMember(list.email(),user.email(),state,token).save();
}
public static void createTable() throws SQLException {
@ -94,6 +99,10 @@ public class ListMember { @@ -94,6 +99,10 @@ public class ListMember {
return this;
}
public String token() {
return token;
}
public static void unsubscribe(MailingList list, User user) throws SQLException {
var db = Database.open();
var rs = db.select(TABLE_NAME)
@ -116,4 +125,5 @@ public class ListMember { @@ -116,4 +125,5 @@ public class ListMember {
}
}
}
}

146
src/main/java/de/srsoftware/widerhall/data/MailingList.java

@ -1,9 +1,12 @@ @@ -1,9 +1,12 @@
package de.srsoftware.widerhall.data;
import de.srsoftware.widerhall.Configuration;
import de.srsoftware.widerhall.mail.SmtpClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.xml.crypto.Data;
import javax.mail.MessagingException;
import java.io.UnsupportedEncodingException;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.HashMap;
@ -11,6 +14,7 @@ import java.util.List; @@ -11,6 +14,7 @@ import java.util.List;
import java.util.Map;
import static de.srsoftware.widerhall.Constants.*;
import static de.srsoftware.widerhall.Util.t;
public class MailingList {
private static final Logger LOG = LoggerFactory.getLogger(MailingList.class);
@ -22,13 +26,17 @@ public class MailingList { @@ -22,13 +26,17 @@ public class MailingList {
private static final String SMTP_PORT = "smtp_port";
private static final String SMTP_USER = "smtp_user";
private static final String SMTP_PASS = "smtp_pass";
private static final int ENABLED = 1;
private static final int PUBLIC = 2;
private static final int STATE_PENDING = 0;
private static final int STATE_ENABLED = 1;
private static final int STATE_PUBLIC = 2;
private final String name;
private final String email;
public static final String TABLE_NAME = "Lists";
private final String imapPass, smtpPass, imapHost, smtpHost, imapUser, smtpUser;
private final int imapPort, smtpPort, state;
private final String imapPass, imapHost, imapUser;
private final int imapPort, state;
private final SmtpClient smtp;
private static final HashMap<String,MailingList> lists = new HashMap<>();
public MailingList(String email, String name, String imapHost, int imapPort, String imapUser, String imapPass, String smtpHost, int smtpPort, String smtpUser, String smtpPass, int state) {
this.email = email;
@ -37,15 +45,12 @@ public class MailingList { @@ -37,15 +45,12 @@ public class MailingList {
this.imapPort = imapPort;
this.imapUser = imapUser;
this.imapPass = imapPass;
this.smtpHost = smtpHost;
this.smtpPort = smtpPort;
this.smtpUser = smtpUser;
this.smtpPass = smtpPass;
this.state = state;
this.smtp = new SmtpClient(smtpHost,smtpPort,smtpUser,smtpPass);
}
public static MailingList create(String email, String name, String imapHost, int imapPort, String imapUser, String imapPass, String smtpHost, int smtpPort, String smtpUser, String smtpPass) throws SQLException {
return new MailingList(email, name, imapHost, imapPort, imapUser, imapPass, smtpHost, smtpPort, smtpUser, smtpPass, ENABLED).save();
return new MailingList(email, name, imapHost, imapPort, imapUser, imapPass, smtpHost, smtpPort, smtpUser, smtpPass, STATE_PENDING).save();
}
public static void createTable() throws SQLException {
@ -67,18 +72,28 @@ public class MailingList { @@ -67,18 +72,28 @@ public class MailingList {
Database.open().query(sql).run();
}
public String email() {
return email;
}
public static void enable(String listEmail, boolean enable) throws SQLException {
// https://stackoverflow.com/questions/16440831/bitwise-xor-in-sqlite-bitwise-not-not-working-as-i-expect
String expression = enable ? "state = state | "+ENABLED : "state = (~(state & "+ENABLED+"))&(state|"+ENABLED+")";
String expression = enable ? "state = state | "+ STATE_ENABLED : "state = (~(state & "+ STATE_ENABLED +"))&(state|"+ STATE_ENABLED +")";
Database.open().update(TABLE_NAME,expression).where(EMAIL, listEmail).run();
}
public static void hide(String listEmail, boolean hide) throws SQLException {
// https://stackoverflow.com/questions/16440831/bitwise-xor-in-sqlite-bitwise-not-not-working-as-i-expect
String expression = hide ? "state = (~(state & "+PUBLIC+"))&(state|"+PUBLIC+")" : ("state = state | "+PUBLIC);
String expression = hide ? "state = (~(state & "+ STATE_PUBLIC +"))&(state|"+ STATE_PUBLIC +")" : ("state = state | "+ STATE_PUBLIC);
Database.open().update(TABLE_NAME,expression).where(EMAIL, listEmail).run();
}
public static boolean isOpen(String list) {
return openLists().stream().filter(ml -> ml.email.equals(list)).count() > 0;
}
public static List<MailingList> listsOf(User user) {
List<String> keys = (user.is(ADMIN)) ? null : ListMember.listsOwnedBy(user);
@ -108,37 +123,16 @@ public class MailingList { @@ -108,37 +123,16 @@ public class MailingList {
return list;
}
public static List<MailingList> openLists() {
var list = new ArrayList<MailingList>();
try {
var rs = Database.open()
.select(TABLE_NAME,"*", "(" + STATE + " & " + PUBLIC + ") as test")
.where("test", PUBLIC)
.exec();
while (rs.next()) {
var email = rs.getString(EMAIL);
var name = rs.getString(NAME);
var state = rs.getInt(STATE);
list.add(new MailingList(email, name, null, 0, null, null, null, 0, null, null, state));
}
} catch (SQLException e) {
LOG.warn("Listing mailing lists failed: ", e);
}
return list;
}
public static boolean isOpen(String list) {
return openLists().stream().filter(ml -> ml.email.equals(list)).count() > 0;
}
public static MailingList load(String listEmail) {
try {
var ml = lists.get(listEmail);
if (ml == null) try {
var rs = Database.open()
.select(TABLE_NAME)
.where(EMAIL,listEmail)
.exec();
if (rs.next()){
return new MailingList(rs.getString(EMAIL),
ml = new MailingList(rs.getString(EMAIL),
rs.getString(NAME),
rs.getString(IMAP_HOST),
rs.getInt(IMAP_PORT),
@ -149,13 +143,38 @@ public class MailingList { @@ -149,13 +143,38 @@ public class MailingList {
rs.getString(SMTP_USER),
rs.getString(SMTP_PASS),
rs.getInt(STATE));
lists.put(listEmail,ml);
}
} catch (SQLException e) {
LOG.debug("Failed to load MailingList: ",e);
}
return null;
return ml;
}
public String name(){
return name;
}
public static List<MailingList> openLists() {
var list = new ArrayList<MailingList>();
try {
var rs = Database.open()
.select(TABLE_NAME,"*", "(" + STATE + " & " + STATE_PUBLIC + ") as test")
.where("test", STATE_PUBLIC)
.exec();
while (rs.next()) {
var email = rs.getString(EMAIL);
var name = rs.getString(NAME);
var state = rs.getInt(STATE);
list.add(new MailingList(email, name, null, 0, null, null, null, 0, null, null, state));
}
} catch (SQLException e) {
LOG.warn("Listing mailing lists failed: ", e);
}
return list;
}
public Map<String, Object> safeMap() {
var map = new HashMap<String,Object>();
String[] parts = email.split("@", 2);
@ -164,19 +183,14 @@ public class MailingList { @@ -164,19 +183,14 @@ public class MailingList {
if (imapHost != null) map.put(IMAP_HOST, imapHost);
if (imapPort != 0) map.put(IMAP_PORT, imapPort);
if (imapUser != null) map.put(IMAP_USER, imapUser);
if (smtpHost != null) map.put(SMTP_HOST, smtpHost);
if (smtpPort != 0) map.put(SMTP_PORT, smtpPort);
if (smtpUser != null) map.put(SMTP_USER, smtpUser);
if (smtp.host() != null) map.put(SMTP_HOST, smtp.host());
if (smtp.port() != 0) map.put(SMTP_PORT, smtp.port());
if (smtp.username() != null) map.put(SMTP_USER, smtp.username());
map.put(STATE, stateString(state));
return map;
}
private static String stateString(int state) {
var states = new ArrayList<String>();
states.add((state & ENABLED) == ENABLED ? "enabled" : "disabled");
states.add((state & PUBLIC) == PUBLIC ? "public" : "hidden");
return String.join(", ", states);
}
private MailingList save() throws SQLException {
Database.open().insertInto(TABLE_NAME)
@ -187,16 +201,44 @@ public class MailingList { @@ -187,16 +201,44 @@ public class MailingList {
Map.entry(IMAP_PORT, imapPort),
Map.entry(IMAP_USER, imapUser),
Map.entry(IMAP_PASS, imapPass),
Map.entry(SMTP_HOST, smtpHost),
Map.entry(SMTP_PORT, smtpPort),
Map.entry(SMTP_USER, smtpUser),
Map.entry(SMTP_PASS, smtpPass),
Map.entry(SMTP_HOST, smtp.host()),
Map.entry(SMTP_PORT, smtp.port()),
Map.entry(SMTP_USER, smtp.username()),
Map.entry(SMTP_PASS, smtp.password()),
Map.entry(STATE, state)))
.run();
return this;
}
public String email() {
return email;
private void sendConfirmationRequest(User user, String token) throws MessagingException, UnsupportedEncodingException {
var subject = t("Please confirm your list subscription");
var config = Configuration.instance();
var url = new StringBuilder(config.baseUrl()).append("/confirm?token=").append(token);
var text = t("Please go to {} in order to complete your list subscription!",url);
smtp.login().send(email(),name(),user.email(),subject,text);
}
private static String stateString(int state) {
var states = new ArrayList<String>();
states.add((state & STATE_ENABLED) == STATE_ENABLED ? "enabled" : "disabled");
states.add((state & STATE_PUBLIC) == STATE_PUBLIC ? "public" : "hidden");
return String.join(", ", states);
}
public void requestSubscription(User user) throws SQLException, MessagingException {
var member = ListMember.create(this,user,ListMember.STATE_AWAITING_CONFIRMATION);
var token = member.token();
try {
sendConfirmationRequest(user, token);
} catch (UnsupportedEncodingException e) {
throw new MessagingException("Failed to send email to "+user.email(),e);
}
}
public void test(User user) throws MessagingException, UnsupportedEncodingException {
var subject = t("{}: test mail",name());
var text = t("If you received this mail, the SMTP settings of your mailing list are correct.");
smtp.login().send(email(),name(),user.email(),subject,text);
}
}

13
src/main/java/de/srsoftware/widerhall/mail/Forwarder.java

@ -11,22 +11,21 @@ import java.io.UnsupportedEncodingException; @@ -11,22 +11,21 @@ import java.io.UnsupportedEncodingException;
public class Forwarder implements MessageHandler {
private static final Logger LOG = LoggerFactory.getLogger(Forwarder.class);
private final SmtpClient smtp;
private final JSONObject config;
private final String receiver,sender;
public Forwarder(JSONObject config) {
this.config = config;
SmtpClient smtp = new SmtpClient(config);
public Forwarder(String host, int port, String username, String password, String sender, String receiver) {
this.sender = sender;
this.receiver = receiver;
SmtpClient smtp = new SmtpClient(host,port,username,password);
this.smtp = smtp;
}
@Override
public void onMessageReceived(Message message) throws MessagingException {
LOG.debug("forwarding {}",message.getSubject());
String testSender = (String) config.get("sender");
String testReceiver = (String) config.get("receiver");
try {
smtp.send(config,testSender,"Stephan Richter",testReceiver,"Info: "+message.getSubject(),"Neue Mail eingegangen!");
smtp.send(sender,"Stephan Richter",receiver,"Info: "+message.getSubject(),"Neue Mail eingegangen!");
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}

54
src/main/java/de/srsoftware/widerhall/mail/SmtpClient.java

@ -1,6 +1,5 @@ @@ -1,6 +1,5 @@
package de.srsoftware.widerhall.mail;
import org.json.simple.JSONObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -9,7 +8,6 @@ import javax.mail.internet.InternetAddress; @@ -9,7 +8,6 @@ import javax.mail.internet.InternetAddress;
import javax.mail.internet.MimeMessage;
import java.io.UnsupportedEncodingException;
import java.util.Date;
import java.util.Map;
import java.util.Properties;
public class SmtpClient {
@ -19,23 +17,33 @@ public class SmtpClient { @@ -19,23 +17,33 @@ public class SmtpClient {
private static final String AUTH = "mail.smtp.auth";
private static final String SSL = "mail.smtp.ssl.enable";
private static final String UTF8 = "UTF-8";
private final String host,password,username;
private final int port;
private Session session;
public SmtpClient(Map<String,Object> config){
String host = (String) config.get("host");
long port = (long) config.get("port");
Properties props = new Properties();
props.put(HOST,host);
props.put(PORT,port);
props.put(AUTH,true);
props.put(SSL,true);
session = Session.getInstance(props);
LOG.debug("Created new {}: {}", getClass().getSimpleName(),session);
public SmtpClient(String host, int port, String username, String password){
this.username = username;
this.password = password;
this.host = host;
this.port = port;
}
public void send(JSONObject config, String senderAdress, String senderName, String receivers, String subject, String content) throws MessagingException, UnsupportedEncodingException {
public SmtpClient login(){
if (session == null) {
Properties props = new Properties();
props.put(HOST, host);
props.put(PORT, port);
props.put(AUTH, true);
props.put(SSL, true);
session = Session.getInstance(props);
LOG.debug("Created new session: {}", session);
}
return this;
}
public void send(String senderAdress, String senderName, String receivers, String subject, String content) throws MessagingException, UnsupportedEncodingException {
MimeMessage message = new MimeMessage(session);
message.addHeader("Content-Type","text/plain; charset="+UTF8);
message.addHeader("format","flowed");
@ -48,12 +56,24 @@ public class SmtpClient { @@ -48,12 +56,24 @@ public class SmtpClient {
message.setSentDate(new Date());
message.setRecipients(Message.RecipientType.TO,InternetAddress.parse(receivers,false));
String username = (String) config.get("user");
String password = (String) config.get("password");
LOG.debug("Versende Mail…");
Transport.send(message,username,password);
LOG.debug("…versendet");
}
public String host() {
return host;
}
public int port() {
return port;
}
public String username() {
return username;
}
public String password() {
return password;
}
}

23
src/main/java/de/srsoftware/widerhall/web/Rest.java

@ -7,12 +7,14 @@ import org.json.simple.JSONObject; @@ -7,12 +7,14 @@ import org.json.simple.JSONObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.mail.MessagingException;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.Serializable;
import java.io.UnsupportedEncodingException;
import java.sql.SQLException;
import java.util.List;
import java.util.Map;
@ -29,8 +31,10 @@ public class Rest extends HttpServlet { @@ -29,8 +31,10 @@ public class Rest extends HttpServlet {
private static final String LIST_HIDE = "list/hide";
private static final String LIST_MEMBERS = "list/members";
private static final String LIST_SHOW = "list/show";
private static final String LIST_TEST = "list/test";
private static final String USER_LIST = "user/list";
private static final String MEMBERS = "members";
private static final String SUCCESS = "success";
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
@ -113,6 +117,9 @@ public class Rest extends HttpServlet { @@ -113,6 +117,9 @@ public class Rest extends HttpServlet {
case LIST_SHOW:
json.putAll(hideList(listEmail,user,false));
break;
case LIST_TEST:
json.putAll(testList(listEmail,user));
break;
default:
json.put(ERROR,t("No handler for path '{}'!",path));
break;
@ -154,7 +161,7 @@ public class Rest extends HttpServlet { @@ -154,7 +161,7 @@ public class Rest extends HttpServlet {
if (user.is(ADMIN) || ListMember.listsOwnedBy(user).contains(listEmail)){
try {
MailingList.enable(listEmail,enable);
return Map.of("success",t("Mailing list '{}' was {}!",listEmail,enable ? "enabled" : "disabled"));
return Map.of(SUCCESS,t("Mailing list '{}' was {}!",listEmail,enable ? "enabled" : "disabled"));
} catch (SQLException e) {
LOG.error("Failed to enable/disable mailing list: ",e);
return Map.of("error",t("Failed to update list '{}'",listEmail));
@ -167,15 +174,23 @@ public class Rest extends HttpServlet { @@ -167,15 +174,23 @@ public class Rest extends HttpServlet {
if (user.is(ADMIN) || ListMember.listsOwnedBy(user).contains(listEmail)){
try {
MailingList.hide(listEmail,hide);
return Map.of("success",t("Mailing list '{}' was {}!",listEmail,hide ? "hidden" : "made public"));
return Map.of(SUCCESS,t("Mailing list '{}' was {}!",listEmail,hide ? "hidden" : "made public"));
} catch (SQLException e) {
LOG.error("Failed to (un)hide mailing list: ",e);
return Map.of("error",t("Failed to update list '{}'",listEmail));
}
} else {
return Map.of("error",t("You are not allowed to edit '{}'",listEmail));
}
return Map.of("error",t("You are not allowed to edit '{}'",listEmail));
}
private Map testList(String listEmail, User user) {
try {
MailingList.load(listEmail).test(user);
return Map.of(SUCCESS,t("Sent test email to {}",user.email()));
} catch (Exception e) {
LOG.warn("Failed to send test email",e);
return Map.of(ERROR,t("Failed to send test email to {}",user.email()));
}
}
}

7
src/main/java/de/srsoftware/widerhall/web/Web.java

@ -10,6 +10,7 @@ import org.slf4j.LoggerFactory; @@ -10,6 +10,7 @@ import org.slf4j.LoggerFactory;
import org.stringtemplate.v4.STGroup;
import org.stringtemplate.v4.STRawGroupDir;
import javax.mail.MessagingException;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
@ -360,7 +361,7 @@ public class Web extends HttpServlet { @@ -360,7 +361,7 @@ public class Web extends HttpServlet {
data.put(USER,user.safeMap());
try {
ListMember.create(list,user,ListMember.STATE_SUBSCRIBER);
list.requestSubscription(user);
data.put(NOTES,t("Successfully subscribed '{}' to '{}'.",user.email(),list.email()));
return loadTemplate(INDEX,data,resp);
} catch (SQLException sqle) {
@ -371,6 +372,10 @@ public class Web extends HttpServlet { @@ -371,6 +372,10 @@ public class Web extends HttpServlet {
data.put(ERROR,t("You already are member of this list!",sqle.getMessage()));
} else data.put(ERROR,t("Subscription failed: {}",sqle.getMessage()));
return loadTemplate(SUBSCRIBE,data,resp);
} catch (MessagingException e) {
LOG.warn("Failed to send request confirmation email:",e);
data.put(ERROR,t("Failed to send request confirmation email: {}",e.getMessage()));
return loadTemplate(SUBSCRIBE,data,resp);
}
}

6
static/templates/js.st

@ -58,7 +58,7 @@ function showListAdminList(data){ @@ -58,7 +58,7 @@ function showListAdminList(data){
if (confirm("This will "+action+" '"+list+"'. Are you sure?"))self[action+'List'](list);
});
$('<option/>').text('Actions').appendTo(select);
['enable','disable','drop','hide','show'].forEach(val => $('<option/>',{value:val}).text(val).appendTo(select));
['enable','disable','drop','hide','show','test'].forEach(val => $('<option/>',{value:val}).text(val).appendTo(select));
select.appendTo($('<td/>')).appendTo(row);
@ -145,6 +145,10 @@ function subscribeTo(domain,prefix){ @@ -145,6 +145,10 @@ function subscribeTo(domain,prefix){
window.location.href='subscribe?list='+prefix+'@'+domain;
}
function testList(listEmail){
$.post('/api/list/test',{list:listEmail},showListResult,'json');
}
function unsubscribeFrom(domain,prefix){
window.location.href='unsubscribe?list='+prefix+'@'+domain;
}

Loading…
Cancel
Save